384 lines
13 KiB
JavaScript
384 lines
13 KiB
JavaScript
// Local/remote filesystem operations shared by the toolbar, sidebar, file
|
|
// table context menus and drag&drop handlers. Mirrors the old
|
|
// src/ui/panels/{local_pane,remote_pane}/actions.rs + app/ops.rs.
|
|
import { state, notify, activeTab } from "./store.js";
|
|
import * as ipc from "./ipc.js";
|
|
import { t } from "./i18n.js";
|
|
|
|
function withParent(entries, path) {
|
|
if (path === "/" || path === "") return entries;
|
|
return [{ name: "..", path: parentPath(path), kind: "dir", size: null, modified: null, permissions: null }, ...entries];
|
|
}
|
|
|
|
export function parentPath(path) {
|
|
const trimmed = path.replace(/\/+$/, "");
|
|
if (!trimmed || trimmed === "") return "/";
|
|
const idx = trimmed.lastIndexOf("/");
|
|
if (idx <= 0) return "/";
|
|
return trimmed.slice(0, idx);
|
|
}
|
|
|
|
// --- Local -------------------------------------------------------------------
|
|
|
|
export async function refreshLocal() {
|
|
try {
|
|
const entries = await ipc.localList(state.local.path);
|
|
state.local.entries = withParent(entries, state.local.path);
|
|
notify();
|
|
} catch (err) {
|
|
state.statusMessage = `Local list error: ${err}`;
|
|
notify();
|
|
}
|
|
}
|
|
|
|
export function navigateLocal(path) {
|
|
state.local.path = path;
|
|
state.local.selected = [];
|
|
state.local.selectAnchor = null;
|
|
// Navigating local (sidebar quick-access/bookmarks especially) has to
|
|
// bring the Local pane into view -- otherwise clicking it while a remote
|
|
// tab is displayed silently updates state nobody's looking at and reads
|
|
// as "nothing happened". notify() here (before the async list call even
|
|
// starts) is what makes the tab switch itself feel instant.
|
|
state.activeTabId = null;
|
|
state.activePane = "local";
|
|
notify();
|
|
refreshLocal();
|
|
}
|
|
|
|
export async function initLocal() {
|
|
const home = await ipc.localHomeDir();
|
|
state.local.path = home;
|
|
const qa = state.sidebar.quickAccess;
|
|
const set = (name, suffix) => {
|
|
const item = qa.find((q) => q.name === name);
|
|
if (item) item.path = suffix ? `${home}/${suffix}` : home;
|
|
};
|
|
set("Home", "");
|
|
set("Desktop", "Desktop");
|
|
set("Documents", "Documents");
|
|
set("Downloads", "Downloads");
|
|
set("Pictures", "Pictures");
|
|
set("Music", "Music");
|
|
set("Videos", "Videos");
|
|
await refreshLocal();
|
|
try {
|
|
state.sidebar.bookmarks = await ipc.listBookmarks();
|
|
} catch {
|
|
/* bookmarks table may be empty, fine */
|
|
}
|
|
notify();
|
|
}
|
|
|
|
export function openLocalEntry(name) {
|
|
if (name === "..") {
|
|
navigateLocal(parentPath(state.local.path));
|
|
return;
|
|
}
|
|
const entry = state.local.entries.find((e) => e.name === name);
|
|
if (!entry) return;
|
|
if (entry.kind === "dir") {
|
|
navigateLocal(entry.path);
|
|
} else {
|
|
ipc
|
|
.localOpen(entry.path)
|
|
.then(() => {
|
|
state.statusMessage = t("panels.opening", { name: entry.name });
|
|
notify();
|
|
})
|
|
.catch((err) => {
|
|
state.statusMessage = `Open failed: ${err}`;
|
|
notify();
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function addBookmark(name, path) {
|
|
if (state.sidebar.bookmarks.some((b) => b.path === path)) {
|
|
state.statusMessage = t("panels.alreadyBookmarked");
|
|
notify();
|
|
return;
|
|
}
|
|
const id = await ipc.addBookmark(name, path);
|
|
state.sidebar.bookmarks.push({ id, name, path });
|
|
state.statusMessage = t("panels.bookmarkAdded");
|
|
notify();
|
|
}
|
|
|
|
export async function removeBookmark(id) {
|
|
await ipc.removeBookmark(id);
|
|
state.sidebar.bookmarks = state.sidebar.bookmarks.filter((b) => b.id !== id);
|
|
notify();
|
|
}
|
|
|
|
// --- Remote --------------------------------------------------------------------
|
|
|
|
export async function refreshRemote(tab) {
|
|
tab.loading = true;
|
|
notify();
|
|
try {
|
|
const entries = await ipc.remoteList(tab.id, tab.remotePath);
|
|
tab.remoteEntries = withParent(entries, tab.remotePath);
|
|
} catch (err) {
|
|
state.statusMessage = `Remote list error: ${err}`;
|
|
} finally {
|
|
tab.loading = false;
|
|
notify();
|
|
}
|
|
}
|
|
|
|
export function navigateRemote(tab, path) {
|
|
tab.remotePath = path;
|
|
tab.remoteSelected = [];
|
|
tab.remoteSelectAnchor = null;
|
|
if (!tab.loading) refreshRemote(tab);
|
|
}
|
|
|
|
export function openRemoteEntry(tab, name) {
|
|
if (name === "..") {
|
|
navigateRemote(tab, parentPath(tab.remotePath));
|
|
return;
|
|
}
|
|
const entry = tab.remoteEntries.find((e) => e.name === name);
|
|
if (!entry) return;
|
|
if (entry.kind === "dir") {
|
|
navigateRemote(tab, entry.path);
|
|
} else {
|
|
queueDownload(tab, entry);
|
|
}
|
|
}
|
|
|
|
export function closeTab(tabId) {
|
|
ipc.disconnect(tabId);
|
|
state.tabs = state.tabs.filter((t) => t.id !== tabId);
|
|
// Clean up layout — remove panes referencing this tab, or reset to Local
|
|
state.layout.panes = state.layout.panes
|
|
.map((p) => (p.tabId === tabId ? { ...p, tabId: null } : p))
|
|
// If we ended up with duplicate Local panes, merge them
|
|
.filter((p, i, arr) => p.tabId !== null || arr.findIndex((x) => x.tabId === null) === i);
|
|
// Keep at least one pane
|
|
if (state.layout.panes.length === 0) {
|
|
state.layout.panes = [{ id: "pane-0", tabId: null }];
|
|
}
|
|
if (state.activeTabId === tabId) {
|
|
state.activeTabId = null;
|
|
state.activePane = "local";
|
|
}
|
|
if (state.lastRemoteTabId === tabId) {
|
|
state.lastRemoteTabId = state.tabs.at(-1)?.id ?? null;
|
|
}
|
|
notify();
|
|
}
|
|
|
|
// --- Transfers -------------------------------------------------------------------
|
|
|
|
// Optimistic insert so the queue panel/toolbar counter update the instant the
|
|
// user clicks Upload/Download, instead of waiting for the worker to pick the
|
|
// task up and emit its first transfer-state-changed event.
|
|
function addOptimisticTask(id, kind, connectionId, localPath, remotePath, fileName, totalBytes) {
|
|
state.queueTasks.push({
|
|
id,
|
|
kind,
|
|
connectionId,
|
|
localPath,
|
|
remotePath,
|
|
fileName,
|
|
totalBytes,
|
|
transferredBytes: 0,
|
|
state: "queued",
|
|
speed: null,
|
|
etaSecs: null,
|
|
});
|
|
}
|
|
|
|
export async function queueUpload(tab, localEntry) {
|
|
const remotePath = `${tab.remotePath.replace(/\/+$/, "")}/${localEntry.name}`;
|
|
const totalBytes = localEntry.size ?? 0;
|
|
const id = await ipc.enqueueTransfer("upload", tab.id, localEntry.path, remotePath, localEntry.name, totalBytes);
|
|
addOptimisticTask(id, "upload", tab.id, localEntry.path, remotePath, localEntry.name, totalBytes);
|
|
state.statusMessage = t("toolbar.uploadQueued", { name: localEntry.name });
|
|
notify();
|
|
}
|
|
|
|
export async function queueDownload(tab, remoteEntry) {
|
|
const localPath = `${state.local.path.replace(/\/+$/, "")}/${remoteEntry.name}`;
|
|
const totalBytes = remoteEntry.size ?? 0;
|
|
const id = await ipc.enqueueTransfer("download", tab.id, localPath, remoteEntry.path, remoteEntry.name, totalBytes);
|
|
addOptimisticTask(id, "download", tab.id, localPath, remoteEntry.path, remoteEntry.name, totalBytes);
|
|
state.statusMessage = t("toolbar.downloadQueued", { name: remoteEntry.name });
|
|
notify();
|
|
}
|
|
|
|
export async function queueUploadMany(tab, localEntries) {
|
|
if (localEntries.length === 1) return queueUpload(tab, localEntries[0]);
|
|
for (const entry of localEntries) {
|
|
const remotePath = `${tab.remotePath.replace(/\/+$/, "")}/${entry.name}`;
|
|
const totalBytes = entry.size ?? 0;
|
|
const id = await ipc.enqueueTransfer("upload", tab.id, entry.path, remotePath, entry.name, totalBytes);
|
|
addOptimisticTask(id, "upload", tab.id, entry.path, remotePath, entry.name, totalBytes);
|
|
}
|
|
state.statusMessage = t("toolbar.uploadQueuedMulti", { n: localEntries.length });
|
|
notify();
|
|
}
|
|
|
|
export async function queueDownloadMany(tab, remoteEntries) {
|
|
if (remoteEntries.length === 1) return queueDownload(tab, remoteEntries[0]);
|
|
for (const entry of remoteEntries) {
|
|
const localPath = `${state.local.path.replace(/\/+$/, "")}/${entry.name}`;
|
|
const totalBytes = entry.size ?? 0;
|
|
const id = await ipc.enqueueTransfer("download", tab.id, localPath, entry.path, entry.name, totalBytes);
|
|
addOptimisticTask(id, "download", tab.id, localPath, entry.path, entry.name, totalBytes);
|
|
}
|
|
state.statusMessage = t("toolbar.downloadQueuedMulti", { n: remoteEntries.length });
|
|
notify();
|
|
}
|
|
|
|
// --- Drag & drop -----------------------------------------------------------------
|
|
|
|
// A folder can't be dropped into itself or into one of its own descendants —
|
|
// filters those out before a move is attempted so it fails silently instead
|
|
// of round-tripping an OS-level rename error to the status bar.
|
|
function isSelfOrDescendant(itemPath, destDir) {
|
|
const src = itemPath.replace(/\/+$/, "");
|
|
return destDir === src || destDir.startsWith(`${src}/`);
|
|
}
|
|
|
|
export async function handleDropOnLocal(payload, destDir) {
|
|
const items = payload.items ?? [];
|
|
if (payload.source === "remote") {
|
|
const tab = state.tabs.find((tb) => tb.id === payload.connectionId);
|
|
if (!tab) {
|
|
state.statusMessage = t("panels.noActiveConnection");
|
|
notify();
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
const localPath = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
|
const id = await ipc.enqueueTransfer("download", tab.id, localPath, item.path, item.name, 0);
|
|
addOptimisticTask(id, "download", tab.id, localPath, item.path, item.name, 0);
|
|
}
|
|
state.statusMessage =
|
|
items.length === 1 ? t("toolbar.downloadQueued", { name: items[0].name }) : t("toolbar.downloadQueuedMulti", { n: items.length });
|
|
notify();
|
|
} else {
|
|
const toMove = items.filter((item) => parentPath(item.path) !== destDir && !isSelfOrDescendant(item.path, destDir));
|
|
if (toMove.length === 0) return;
|
|
let failed = 0;
|
|
for (const item of toMove) {
|
|
try {
|
|
await ipc.localMoveInto(item.path, destDir);
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
await refreshLocal();
|
|
state.statusMessage = failed > 0 ? t("panels.moveFailedCount", { n: failed }) : t("panels.moved", { n: toMove.length });
|
|
notify();
|
|
}
|
|
}
|
|
|
|
export async function handleDropOnRemote(tab, payload, destDir) {
|
|
const items = payload.items ?? [];
|
|
if (payload.source === "local") {
|
|
for (const item of items) {
|
|
const dest = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
|
const id = await ipc.enqueueTransfer("upload", tab.id, item.path, dest, item.name, 0);
|
|
addOptimisticTask(id, "upload", tab.id, item.path, dest, item.name, 0);
|
|
}
|
|
state.statusMessage =
|
|
items.length === 1 ? t("toolbar.uploadQueued", { name: items[0].name }) : t("toolbar.uploadQueuedMulti", { n: items.length });
|
|
notify();
|
|
return;
|
|
}
|
|
if (payload.connectionId !== tab.id) {
|
|
state.statusMessage = t("panels.cannotMoveCrossConnection");
|
|
notify();
|
|
return;
|
|
}
|
|
const toMove = items.filter((item) => parentPath(item.path) !== destDir && !isSelfOrDescendant(item.path, destDir));
|
|
if (toMove.length === 0) return;
|
|
state.statusMessage =
|
|
toMove.length === 1 ? t("panels.movingFile", { name: toMove[0].name }) : t("panels.movingFiles", { n: toMove.length });
|
|
notify();
|
|
let failed = 0;
|
|
for (const item of toMove) {
|
|
const dest = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
|
try {
|
|
await ipc.remoteRename(tab.id, item.path, dest);
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
await refreshRemote(tab);
|
|
state.statusMessage = failed > 0 ? t("panels.moveFailedCount", { n: failed }) : t("panels.moved", { n: toMove.length });
|
|
notify();
|
|
}
|
|
|
|
// --- New Folder / Rename / Delete -------------------------------------------------
|
|
|
|
export async function doMkdir(name) {
|
|
if (state.opTarget === "local") {
|
|
const path = `${state.local.path.replace(/\/+$/, "")}/${name}`;
|
|
await ipc.localMkdir(path);
|
|
state.statusMessage = "Folder created";
|
|
await refreshLocal();
|
|
} else {
|
|
const tab = activeTab();
|
|
if (!tab) return;
|
|
const path = `${tab.remotePath.replace(/\/+$/, "")}/${name}`;
|
|
await ipc.remoteMkdir(tab.id, path);
|
|
state.statusMessage = "Folder created";
|
|
await refreshRemote(tab);
|
|
}
|
|
}
|
|
|
|
export async function doRename(oldName, newName) {
|
|
if (state.opTarget === "local") {
|
|
const entry = state.local.entries.find((e) => e.name === oldName);
|
|
if (!entry) return;
|
|
const to = `${parentPath(entry.path)}/${newName}`;
|
|
await ipc.localRename(entry.path, to);
|
|
state.statusMessage = "Renamed";
|
|
await refreshLocal();
|
|
} else {
|
|
const tab = activeTab();
|
|
if (!tab) return;
|
|
const entry = tab.remoteEntries.find((e) => e.name === oldName);
|
|
if (!entry) return;
|
|
const to = `${parentPath(entry.path)}/${newName}`;
|
|
await ipc.remoteRename(tab.id, entry.path, to);
|
|
state.statusMessage = "Renamed";
|
|
await refreshRemote(tab);
|
|
}
|
|
}
|
|
|
|
export async function doDelete(names) {
|
|
let failed = 0;
|
|
if (state.opTarget === "local") {
|
|
for (const name of names) {
|
|
const entry = state.local.entries.find((e) => e.name === name);
|
|
if (!entry) continue;
|
|
try {
|
|
await ipc.localDelete(entry.path);
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
state.statusMessage = failed > 0 ? t("panels.deleteFailedCount", { n: failed }) : t("panels.deleted", { n: names.length });
|
|
await refreshLocal();
|
|
} else {
|
|
const tab = activeTab();
|
|
if (!tab) return;
|
|
for (const name of names) {
|
|
const entry = tab.remoteEntries.find((e) => e.name === name);
|
|
if (!entry) continue;
|
|
try {
|
|
await ipc.remoteDelete(tab.id, entry.path);
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
state.statusMessage = failed > 0 ? t("panels.deleteFailedCount", { n: failed }) : t("panels.deleted", { n: names.length });
|
|
await refreshRemote(tab);
|
|
}
|
|
}
|