дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Unity projects (a folder with Assets and ProjectSettings/ProjectVersion.txt) are detected without a generated solution: Assets / Packages become source roots, ProjectSettings resources, and Unity's cache and build folders are excluded before they exist. A Unity pane in the Project view shows the editor version and hides .meta files, generated C# project files and caches (each can be shown again). Operations on assets under Assets keep their .meta files consistent: rename and move carry the metadata (same GUID), delete removes both, copy generates new GUIDs and updates references inside the copied group, and file-move undo and workspace edits include the metadata. Destination metadata conflicts and links inside the project are rejected before anything changes. Co-authored-by: hacbit <[email protected]>
848 рядки
31 KiB
TypeScript
848 рядки
31 KiB
TypeScript
/**
|
||
* File operations behind the project view's actions: new / rename / delete,
|
||
* internal copy-cut-paste, drag & drop moves and OS file imports.
|
||
*/
|
||
import { createElement, Fragment } from "react";
|
||
import { askUsagesDetected, fileUsages, viewUsages } from "@/editor/refactor";
|
||
import { executeAction, getAction } from "@/platform/actions";
|
||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||
import { ask, confirmChecked } from "@/workbench/messages";
|
||
import { osClipboard } from "./clipboard";
|
||
import { lastFileRefactoring, nextFileOperation, runFileRefactoring, undoFileRefactoring } from "./fileRefactor";
|
||
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
|
||
import { beginLocalImport, projectFs } from "./fs";
|
||
import { call, commands, isCancelled } from "@/platform/ipc";
|
||
import { MEMBER_PREFIX, type ProjectNode } from "./model";
|
||
import {
|
||
isInside,
|
||
isSameOrInside,
|
||
joinPath,
|
||
nameOf,
|
||
packageSegments,
|
||
parentOf,
|
||
relativeTo,
|
||
uniqueName,
|
||
validateName,
|
||
type PackageKind,
|
||
} from "./paths";
|
||
import {
|
||
currentModel,
|
||
type Editing,
|
||
isFilteredMode,
|
||
isReadOnlyPath,
|
||
loadDir,
|
||
revealPath,
|
||
setClipboard,
|
||
setExpanded,
|
||
setViewMode,
|
||
startEditing,
|
||
useProjectTree,
|
||
} from "./store";
|
||
import { tx } from "@/platform/i18n";
|
||
import { isRemoteWindow } from "@/platform/remote";
|
||
|
||
/** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */
|
||
const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly;
|
||
|
||
const get = () => useProjectTree.getState();
|
||
|
||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||
const isWindows = /Win/i.test(ua);
|
||
/** macOS and Windows file systems are case-insensitive by default. */
|
||
const caseInsensitive = /Mac|Win/i.test(ua);
|
||
|
||
function errorMessage(e: unknown): string {
|
||
return e instanceof Error ? e.message : String(e);
|
||
}
|
||
|
||
function fail(title: string, e: unknown) {
|
||
notifications().notify({ level: "error", title, body: errorMessage(e) });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selection helpers
|
||
|
||
/** Selected nodes in tree order (placeholders excluded). */
|
||
export function selectedNodes(): ProjectNode[] {
|
||
const { selected } = get();
|
||
const model = currentModel();
|
||
const out: ProjectNode[] = [];
|
||
for (const key of selected) {
|
||
const n = model.byKey.get(key);
|
||
if (n && !n.placeholder) out.push(n);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function leadNode(): ProjectNode | null {
|
||
const { lead } = get();
|
||
const model = currentModel();
|
||
const n = lead ? model.byKey.get(lead) : undefined;
|
||
if (n && !n.placeholder) return n;
|
||
return selectedNodes()[0] ?? null;
|
||
}
|
||
|
||
/** Selected paths without entries nested in other selected folders. */
|
||
export function topLevelPaths(nodes: ProjectNode[] = selectedNodes()): string[] {
|
||
const paths = assetPaths(nodes.map((n) => n.path));
|
||
return paths.filter((p) => !paths.some((q) => q !== p && isInside(p, q)));
|
||
}
|
||
|
||
/** A visible sidecar is already included by the backend asset operation. */
|
||
function assetPaths(paths: string[]): string[] {
|
||
const { root, unity } = get();
|
||
if (!root || !unity) return paths;
|
||
return paths.filter(
|
||
(p) => !(isInside(p, joinPath(root, "Assets")) && p.endsWith(".meta") && paths.includes(p.slice(0, -5))),
|
||
);
|
||
}
|
||
|
||
function isUnityAssetFolder(path: string): boolean {
|
||
const { root, unity } = get();
|
||
return !!(root && unity && isSameOrInside(path, joinPath(root, "Assets")));
|
||
}
|
||
|
||
/** Folder that receives new / pasted items: the selected folder or the selected file's parent. */
|
||
export function targetFolder(node: ProjectNode | null = leadNode()): string | null {
|
||
if (!node && get().lead?.startsWith(MEMBER_PREFIX)) return null;
|
||
const { root } = get();
|
||
if (!root) return null;
|
||
if (!node) return get().members?.[0] ?? (get().members ? null : root);
|
||
return node.dir ? node.path : parentOf(node.path);
|
||
}
|
||
|
||
function siblingsOf(dir: string): string[] {
|
||
return (get().listings.get(dir) ?? []).map((e) => e.name);
|
||
}
|
||
|
||
function expandFolder(path: string) {
|
||
const model = currentModel();
|
||
const key = model.keyOfPath.get(path) ?? path;
|
||
const { expanded } = get();
|
||
if (expanded.has(key) && expanded.has(path)) return;
|
||
setExpanded(new Set(expanded).add(key).add(path));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Open
|
||
|
||
export async function openFile(path: string, opts: { preview?: boolean; focus?: boolean } = {}) {
|
||
const editor = maybeEditorService();
|
||
if (!editor) {
|
||
notifications().notify({ level: "warning", title: "No editor available", body: path, key: "project.noEditor" });
|
||
return;
|
||
}
|
||
try {
|
||
await editor.openFile(path, { preview: opts.preview, focus: opts.focus ?? true });
|
||
} catch (e) {
|
||
fail(`Cannot open ${nameOf(path)}`, e);
|
||
}
|
||
}
|
||
|
||
export function openSelection() {
|
||
for (const n of selectedNodes()) if (!n.dir) void openFile(n.path);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// New / rename
|
||
|
||
export async function beginNew(nodeKind: "file" | "dir", parent = targetFolder(), pkg?: PackageKind) {
|
||
if (!parent || isReadOnlyPath(parent)) return;
|
||
// The inline editor needs the folder's listing: the filtered panes show none.
|
||
if (isFilteredMode(get().mode)) setViewMode("project");
|
||
expandFolder(parent);
|
||
await loadDir(parent);
|
||
startEditing(pkg ? { kind: "new", parent, nodeKind: "dir", pkg } : { kind: "new", parent, nodeKind });
|
||
}
|
||
|
||
/** New › Package / Python Package: a dotted name creates nested folders. */
|
||
export function beginNewPackage(kind: PackageKind, parent = targetFolder()) {
|
||
return beginNew("dir", parent, kind);
|
||
}
|
||
|
||
/** Folder path (below the parent) the inline editor's text creates. */
|
||
function newRelPath(editing: Extract<Editing, { kind: "new" }>, name: string): string | null {
|
||
if (!editing.pkg) return name;
|
||
return packageSegments(name, editing.pkg).segments?.join("/") ?? null;
|
||
}
|
||
|
||
export function beginRename(node: ProjectNode | null = leadNode()) {
|
||
if (!node || !mutable(node)) return;
|
||
startEditing({ kind: "rename", key: node.key, path: node.path });
|
||
}
|
||
|
||
/** Rename… (⇧F6): IDEA's Rename dialog, updating references. */
|
||
export async function openRenameDialog(node: ProjectNode | null = leadNode()) {
|
||
if (!node || !mutable(node)) return;
|
||
const parent = parentOf(node.path);
|
||
await loadDir(parent);
|
||
openFileRefactorDialog({
|
||
kind: "rename",
|
||
path: node.path,
|
||
dir: node.dir,
|
||
siblings: siblingsOf(parent),
|
||
onDone: (moves) => void afterMove(moves),
|
||
});
|
||
}
|
||
|
||
/** Move… (F6): IDEA's Move dialog for the selection, defaulting to `target` or the current folder. */
|
||
export function openMoveDialog(nodes: ProjectNode[] = selectedNodes(), target?: string) {
|
||
const movable = nodes.filter(mutable);
|
||
const paths = topLevelPaths(movable);
|
||
openMoveDialogFor(paths, target, paths.length === 1 && movable.some((n) => n.path === paths[0] && n.dir));
|
||
}
|
||
|
||
/** Copy… (F5): IDEA's Copy dialog for the selection, into the folder it is in. */
|
||
export function openCopyDialog(nodes: ProjectNode[] = selectedNodes()) {
|
||
const copyable = nodes.filter((n) => !n.root && !n.group);
|
||
const paths = topLevelPaths(copyable);
|
||
if (!paths.length) return;
|
||
const single = paths.length === 1 && copyable.some((n) => n.path === paths[0] && n.dir);
|
||
openCopyDialogFor(paths, parentOf(paths[0]!), single);
|
||
}
|
||
|
||
function openMoveDialogFor(paths: string[], target?: string, dir?: boolean, done?: () => void) {
|
||
if (!paths.length) return;
|
||
openFileRefactorDialog({
|
||
kind: "move",
|
||
paths,
|
||
dir,
|
||
target: target ?? parentOf(paths[0]!),
|
||
onDone: (moves) => {
|
||
done?.();
|
||
void afterMove(moves);
|
||
if (rememberedOptions("move").openInEditor)
|
||
for (const m of moves) if (!get().listings.has(m.to)) void openFile(m.to, { focus: false });
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Refreshes the folders a move touched and selects the moved items. */
|
||
async function afterMove(moves: { from: string; to: string }[]) {
|
||
const dirs = new Set(moves.flatMap((m) => [parentOf(m.from), parentOf(m.to)]));
|
||
await Promise.all([...dirs].map((d) => loadDir(d, true)));
|
||
const first = moves[0];
|
||
if (first) {
|
||
expandFolder(parentOf(first.to));
|
||
revealPath(first.to, true);
|
||
}
|
||
}
|
||
|
||
export function cancelEditing() {
|
||
startEditing(null);
|
||
}
|
||
|
||
/** Validates the inline editor's text against the target folder. */
|
||
export function checkEditingName(name: string) {
|
||
const { editing } = get();
|
||
if (!editing) return { error: null };
|
||
if (editing.kind === "new" && editing.pkg) {
|
||
const { segments, error } = packageSegments(name, editing.pkg);
|
||
if (!segments) return { error };
|
||
return validateName(segments.join("/"), {
|
||
siblings: siblingsOf(editing.parent),
|
||
allowNested: true,
|
||
caseInsensitive,
|
||
windows: isWindows,
|
||
});
|
||
}
|
||
if (editing.kind === "new")
|
||
return validateName(name, {
|
||
siblings: siblingsOf(editing.parent),
|
||
allowNested: true,
|
||
caseInsensitive,
|
||
windows: isWindows,
|
||
});
|
||
return validateName(name, {
|
||
siblings: siblingsOf(parentOf(editing.path)),
|
||
current: nameOf(editing.path),
|
||
caseInsensitive,
|
||
windows: isWindows,
|
||
});
|
||
}
|
||
|
||
/** Commits the inline editor. Resolves false when the name was rejected (editor stays open). */
|
||
export async function commitEditing(name: string): Promise<boolean> {
|
||
const { editing, root } = get();
|
||
if (!editing || !root) return true;
|
||
const check = checkEditingName(name);
|
||
if (check.error) return false;
|
||
const fs = projectFs(root);
|
||
if (editing.kind === "new") {
|
||
const path = joinPath(editing.parent, newRelPath(editing, name) ?? name);
|
||
startEditing(null);
|
||
try {
|
||
if (editing.nodeKind === "file") await fs.createFile(path);
|
||
else await fs.createDir(path);
|
||
// Every folder of a Python package gets an `__init__.py`, as in PyCharm.
|
||
if (editing.pkg === "python")
|
||
for (let dir = path; isInside(dir, editing.parent) && dir !== editing.parent; dir = parentOf(dir))
|
||
// An existing intermediate package keeps its own `__init__.py`.
|
||
await fs.createFile(joinPath(dir, "__init__.py")).catch(() => {});
|
||
} catch (e) {
|
||
fail(editing.nodeKind === "file" ? tx("Cannot create file") : tx("Cannot create folder"), e);
|
||
return true;
|
||
}
|
||
await refreshAfterCreate(editing.parent, path);
|
||
revealPath(path, true);
|
||
if (editing.nodeKind === "file") void openFile(path, { focus: true });
|
||
return true;
|
||
}
|
||
const to = joinPath(parentOf(editing.path), name);
|
||
startEditing(null);
|
||
if (to === editing.path) return true;
|
||
const status = await runFileRefactoring([{ from: editing.path, to }], rememberedOptions("rename"), "Rename");
|
||
if (status !== "done") return true;
|
||
await loadDir(parentOf(to), true);
|
||
revealPath(to, true);
|
||
return true;
|
||
}
|
||
|
||
/** Reloads the folders a (possibly nested `a/b/c`) creation touched. */
|
||
async function refreshAfterCreate(parent: string, path: string) {
|
||
const dirs = [parent];
|
||
for (let p = parentOf(path); isInside(p, parent); p = parentOf(p)) dirs.push(p);
|
||
for (const d of dirs) await loadDir(d, true);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Delete
|
||
|
||
/** The Delete dialog's question, e.g. "Delete 'src' and its contents?". */
|
||
function deleteQuestion(paths: string[], folders: boolean): string {
|
||
if (paths.length === 1) {
|
||
const name = nameOf(paths[0]!);
|
||
return folders ? tx`Delete '${name}' and its contents?` : tx`Delete '${name}'?`;
|
||
}
|
||
const count = paths.length;
|
||
return folders ? tx`Delete ${count} items (including folder contents)?` : tx`Delete ${count} items?`;
|
||
}
|
||
|
||
/** Visible rows in display order (roots, then children of expanded folders). */
|
||
function visibleNodes(): ProjectNode[] {
|
||
const model = currentModel();
|
||
const { expanded } = get();
|
||
const out: ProjectNode[] = [];
|
||
const walk = (list: readonly ProjectNode[]) => {
|
||
for (const n of list) {
|
||
out.push(n);
|
||
if (n.dir && expanded.has(n.key)) walk(model.children.get(n.key) ?? []);
|
||
}
|
||
};
|
||
walk(model.roots);
|
||
return out;
|
||
}
|
||
|
||
/** IDEA keeps a selection after Delete: the next surviving row, else the previous one. */
|
||
export function selectionAfterDelete(rows: readonly ProjectNode[], deleted: readonly string[]): string | null {
|
||
const gone = (n: ProjectNode) => n.placeholder || deleted.some((d) => isSameOrInside(n.path, d));
|
||
let last = -1;
|
||
rows.forEach((n, i) => {
|
||
if (deleted.includes(n.path)) last = i;
|
||
});
|
||
if (last < 0) return null;
|
||
for (let i = last + 1; i < rows.length; i++) if (!gone(rows[i]!)) return rows[i]!.path;
|
||
for (let i = last - 1; i >= 0; i--) if (!gone(rows[i]!)) return rows[i]!.path;
|
||
return null;
|
||
}
|
||
|
||
/** Safe delete searches at most this many files, for at most this long. */
|
||
const SAFE_DELETE_MAX_FILES = 20;
|
||
const SAFE_DELETE_TIMEOUT_MS = 15_000;
|
||
const SAFE_DELETE_PROGRESS_DELAY_MS = 400;
|
||
const SAFE_DELETE_KEY = "openidea.projectTree.safeDelete";
|
||
|
||
function safeDeleteEnabled(): boolean {
|
||
try {
|
||
return localStorage.getItem(SAFE_DELETE_KEY) !== "false";
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* IDEA's safe delete for files: usages of their top-level symbols from other
|
||
* files (where the language server can tell) → "Usages Detected". True to go
|
||
* on deleting; without usage support the plain confirmation stands.
|
||
*/
|
||
export async function safeToDelete(files: string[]): Promise<boolean> {
|
||
if (files.length > SAFE_DELETE_MAX_FILES) return true;
|
||
const abort = new AbortController();
|
||
const timer = setTimeout(() => abort.abort(), SAFE_DELETE_TIMEOUT_MS);
|
||
// A search that takes a while shows progress the user can skip (delete anyway) or cancel.
|
||
const progress = new AbortController();
|
||
const user: { choice: "skip" | "cancel" | null } = { choice: null };
|
||
const shown = setTimeout(() => {
|
||
void ask({
|
||
title: "Delete",
|
||
body: "Looking for usages…",
|
||
icon: "info",
|
||
buttons: [{ id: "skip", label: "Delete Without Search" }],
|
||
signal: progress.signal,
|
||
}).then((r) => {
|
||
if (r.button === "dismissed") return;
|
||
user.choice = r.button === "skip" ? "skip" : "cancel";
|
||
abort.abort();
|
||
});
|
||
}, SAFE_DELETE_PROGRESS_DELAY_MS);
|
||
let result;
|
||
try {
|
||
result = await fileUsages(files, abort.signal);
|
||
} catch {
|
||
if (user.choice) return user.choice === "skip";
|
||
notifications().notify({
|
||
level: "warning",
|
||
title: "Usage search did not finish; nothing was deleted",
|
||
key: "project.safeDelete",
|
||
});
|
||
return false;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
clearTimeout(shown);
|
||
progress.abort();
|
||
}
|
||
if (user.choice) return user.choice === "skip";
|
||
if (result.status !== "ok" || !result.usages.length) return true;
|
||
const name = files.length === 1 ? nameOf(files[0]!) : `${files.length} files`;
|
||
const subject = files.length === 1 ? tx`File '${name}'` : tx`The ${files.length} files`;
|
||
const answer = await askUsagesDetected(subject, result.usages, files.length > 1);
|
||
if (answer === "view") viewUsages(name, result.usages);
|
||
return answer === "delete";
|
||
}
|
||
|
||
/** Find Usages (⌥F7) on a file: usages of its top-level symbols from other files. */
|
||
export async function findFileUsages(path: string) {
|
||
const name = nameOf(path);
|
||
let result;
|
||
try {
|
||
result = await fileUsages([path]);
|
||
} catch (e) {
|
||
fail(`Cannot find usages of '${name}'`, e);
|
||
return;
|
||
}
|
||
if (result.status !== "ok")
|
||
notifications().notify({
|
||
level: "info",
|
||
title: `Cannot find usages of '${name}'`,
|
||
body: result.reason,
|
||
group: "search",
|
||
});
|
||
else if (!result.usages.length)
|
||
notifications().notify({ level: "info", title: `No usages of '${name}' found`, group: "search" });
|
||
else viewUsages(name, result.usages);
|
||
}
|
||
|
||
/** Reload from Disk: re-reads the selected folders (and the folders of selected files) and their open documents. */
|
||
export async function reloadFromDisk(nodes: ProjectNode[] = selectedNodes()) {
|
||
const targets = nodes.filter((n) => !n.placeholder);
|
||
if (!targets.length) return;
|
||
const dirs = new Set(targets.map((n) => (n.dir ? n.path : parentOf(n.path))));
|
||
const loaded = [...get().listings.keys()].filter((p) => [...dirs].some((d) => isSameOrInside(p, d)));
|
||
await Promise.all(loaded.map((p) => loadDir(p, true)));
|
||
const files = targets.filter((n) => !n.dir).map((n) => n.path);
|
||
if (files.length) delegate("file.reloadFromDisk", files);
|
||
}
|
||
|
||
export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
|
||
const { root } = get();
|
||
const paths = topLevelPaths(nodes.filter(mutable));
|
||
if (!root || !paths.length) return;
|
||
// Invoked from the tree (or its context menu): give the tree its focus back afterwards.
|
||
const refocus = typeof document !== "undefined" && !!document.activeElement?.closest('[role="tree"], [role="menu"]');
|
||
const folders = nodes.filter((n) => n.dir && paths.includes(n.path)).length;
|
||
const files = nodes.filter((n) => !n.dir && paths.includes(n.path)).map((n) => n.path);
|
||
// IDEA's Delete dialog: "Safe delete (with usage search)", remembered.
|
||
const searchable = files.length > 0 && files.length <= SAFE_DELETE_MAX_FILES;
|
||
const answer = await confirmChecked({
|
||
title: "Delete",
|
||
ok: "Delete",
|
||
checkbox: searchable ? "Safe delete (with usage search)" : undefined,
|
||
checked: searchable && safeDeleteEnabled(),
|
||
message: createElement(
|
||
Fragment,
|
||
null,
|
||
deleteQuestion(paths, folders > 0),
|
||
createElement("br"),
|
||
tx("Items are moved to the Trash."),
|
||
),
|
||
});
|
||
if (!answer) return;
|
||
if (searchable) {
|
||
try {
|
||
localStorage.setItem(SAFE_DELETE_KEY, String(answer.checked));
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
}
|
||
if (searchable && answer.checked && !(await safeToDelete(files))) return;
|
||
const next = selectionAfterDelete(visibleNodes(), paths);
|
||
const fs = projectFs(root);
|
||
const parents = new Set<string>();
|
||
const errors: string[] = [];
|
||
const deleted: string[] = [];
|
||
for (const p of paths) {
|
||
try {
|
||
await fs.delete(p);
|
||
parents.add(parentOf(p));
|
||
deleted.push(p);
|
||
} catch (e) {
|
||
errors.push(`${nameOf(p)}: ${errorMessage(e)}`);
|
||
}
|
||
}
|
||
if (deleted.length) {
|
||
const dirs = new Set(nodes.filter((n) => n.dir).map((n) => n.path));
|
||
lastDelete = { paths: deleted, dirs: deleted.filter((p) => dirs.has(p)), seq: nextFileOperation() };
|
||
}
|
||
if (errors.length)
|
||
notifications().notify({ level: "error", title: "Some items could not be deleted", body: errors.join("\n") });
|
||
await Promise.all([...parents].map((d) => loadDir(d, true)));
|
||
if (next) revealPath(next, refocus);
|
||
}
|
||
|
||
/** The last Delete in the Project view; Undo brings its files back from Local History. */
|
||
let lastDelete: { paths: string[]; dirs: string[]; seq: number } | null = null;
|
||
|
||
/** Undo in the Project view: reverts the latest delete or move / rename; false when there is none. */
|
||
export function undoLastFileOperation(): boolean {
|
||
const move = lastFileRefactoring();
|
||
if (lastDelete && (!move || lastDelete.seq > move.seq)) {
|
||
void undoDelete();
|
||
return true;
|
||
}
|
||
if (!move) return false;
|
||
void undoFileRefactoring();
|
||
return true;
|
||
}
|
||
|
||
/** Restores the deleted files (and folders) from their last Local History snapshots. */
|
||
export async function undoDelete(): Promise<boolean> {
|
||
const op = lastDelete;
|
||
const { root } = get();
|
||
if (!op || !root) return false;
|
||
lastDelete = null;
|
||
const fs = projectFs(root);
|
||
const failed: string[] = [];
|
||
const parents = new Set<string>();
|
||
for (const p of op.paths) {
|
||
try {
|
||
const history = await call(commands.historyListDir(p));
|
||
const gone = new Set(history.deleted);
|
||
const latest = new Map<string, string>();
|
||
for (const e of history.entries)
|
||
if (e.kind === "snapshot" && e.id && gone.has(e.path) && !latest.has(e.path)) latest.set(e.path, e.id);
|
||
const dir = op.dirs.includes(p);
|
||
if (!latest.size && !dir) throw new Error("no Local History snapshot");
|
||
if (dir) await fs.createDir(p);
|
||
for (const [path, id] of latest) await call(commands.historyRestore(path, id));
|
||
parents.add(parentOf(p));
|
||
} catch {
|
||
failed.push(nameOf(p));
|
||
}
|
||
}
|
||
await Promise.all([...parents].map((d) => loadDir(d, true)));
|
||
const restored = op.paths.find((p) => !failed.includes(nameOf(p)));
|
||
if (restored) revealPath(restored, true);
|
||
const names = failed.join(", ");
|
||
if (failed.length)
|
||
notifications().notify({
|
||
level: "warning",
|
||
title: "Some items could not be restored",
|
||
body: tx`Not in Local History: ${names}. They are in the Trash.`,
|
||
key: "project.undoDelete",
|
||
});
|
||
return !failed.length;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Clipboard / move / copy
|
||
|
||
export function copySelection(cut: boolean) {
|
||
const paths = topLevelPaths(selectedNodes().filter((n) => (cut ? mutable(n) : !n.root && !n.group)));
|
||
if (!paths.length) return;
|
||
// Remote paths are useful as text, but do not name files on the client.
|
||
const remote = isRemoteWindow();
|
||
const osWrite = (async () => {
|
||
if (!remote) return osClipboard().setFiles(paths);
|
||
await navigator.clipboard.writeText(paths.join("\n"));
|
||
return true;
|
||
})().catch(() => false);
|
||
setClipboard({ paths, cut, remote, osWrite });
|
||
}
|
||
|
||
/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */
|
||
let osHasFiles = false;
|
||
|
||
/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */
|
||
export async function refreshOsClipboard() {
|
||
try {
|
||
await readOsClipboard();
|
||
} catch {
|
||
// A busy clipboard is not evidence that the user's copy has changed.
|
||
}
|
||
}
|
||
|
||
async function readOsClipboard(): Promise<string[] | null> {
|
||
const { clipboard } = get();
|
||
const handedOff = await clipboard?.osWrite;
|
||
const external = await osClipboard().files();
|
||
// A newer Copy/Cut owns the state if this read (or its hand-off) was delayed.
|
||
if (get().clipboard !== clipboard) return null;
|
||
osHasFiles = external.length > 0;
|
||
// Our remote text write removes file URLs, so any files were copied later.
|
||
// A failed write leaves ownership uncertain; retain the remote copy then.
|
||
if (handedOff && (clipboard?.remote ? external.length > 0 : !sameFiles(external, clipboard?.paths)))
|
||
setClipboard(null);
|
||
return external;
|
||
}
|
||
|
||
export function canPaste(): boolean {
|
||
return !!get().clipboard?.paths.length || osHasFiles;
|
||
}
|
||
|
||
/**
|
||
* Paste: cut items go through the Move dialog, copied ones through the Copy
|
||
* dialog (IDEA). Files copied in Explorer paste as copies.
|
||
*/
|
||
export async function paste(target = targetFolder()) {
|
||
if (!target || isReadOnlyPath(target)) return;
|
||
let external: string[] = [];
|
||
try {
|
||
const files = await readOsClipboard();
|
||
if (files === null) return;
|
||
external = files;
|
||
} catch (e) {
|
||
if (!get().clipboard?.paths.length) {
|
||
fail(tx("Cannot access the clipboard"), e);
|
||
return;
|
||
}
|
||
}
|
||
const { clipboard } = get();
|
||
// The last copy is the one the user expects to paste, so another app's files
|
||
// win over ours — unless they are the copy we handed to the OS ourselves.
|
||
if (!clipboard?.remote && external.length && !sameFiles(external, clipboard?.paths))
|
||
return pasteFromOsClipboard(external, target);
|
||
if (!clipboard?.paths.length) {
|
||
notifications().notify({ level: "info", title: "No files on the clipboard" });
|
||
return;
|
||
}
|
||
const isDir = (p: string) =>
|
||
get().listings.has(p) || currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)?.dir === true;
|
||
const single = clipboard.paths.length === 1 && isDir(clipboard.paths[0]!);
|
||
if (!clipboard.cut) return openCopyDialogFor(clipboard.paths, target, single);
|
||
const paths = clipboard.paths.filter((p) => parentOf(p) !== target);
|
||
if (!paths.length) return;
|
||
openMoveDialogFor(paths, target, single, () => {
|
||
if (get().clipboard === clipboard) setClipboard(null);
|
||
});
|
||
}
|
||
|
||
/** Pasting from Explorer copies, whatever the source: never a move or a refactoring. */
|
||
async function pasteFromOsClipboard(paths: string[], target: string) {
|
||
osHasFiles = true;
|
||
await transfer(paths, target, "copy", undefined, "local");
|
||
}
|
||
|
||
/** The same files, as the clipboard keeps them (`/`-separated, any order). */
|
||
function sameFiles(files: string[], paths: string[] | undefined): boolean {
|
||
if (!paths || files.length !== paths.length) return false;
|
||
const key = (list: string[]) =>
|
||
list
|
||
.map((p) => (caseInsensitive ? p.toLowerCase() : p))
|
||
.sort()
|
||
.join("\n");
|
||
return key(files) === key(paths);
|
||
}
|
||
|
||
function openCopyDialogFor(paths: string[], target: string, dir?: boolean) {
|
||
if (!paths.length) return;
|
||
void loadDir(target);
|
||
openFileRefactorDialog({
|
||
kind: "copy",
|
||
paths,
|
||
target,
|
||
dir,
|
||
copy: async (to, name, openInEditor) => {
|
||
const first = await transfer(paths, to, "copy", name ?? undefined);
|
||
if (typeof first === "string" && openInEditor) void openFile(first, { focus: true });
|
||
return !!first;
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Moves or copies `paths` into `target`. Copies take `name` (a single item) or
|
||
* get a non-conflicting name (`Foo copy.kt`); moves refuse to overwrite.
|
||
* Returns whether anything changed (for copies: the first copy's path).
|
||
*/
|
||
export async function transfer(
|
||
paths: string[],
|
||
target: string,
|
||
mode: "move" | "copy",
|
||
name?: string,
|
||
source: "project" | "local" = "project",
|
||
): Promise<string | boolean> {
|
||
paths = assetPaths(paths);
|
||
if (mode === "move") return moveInto(paths, target);
|
||
const { root } = get();
|
||
if (!root) return false;
|
||
const fs = projectFs(root);
|
||
await loadDir(target);
|
||
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
|
||
const hasName = (n: string) => taken.has(caseInsensitive ? n.toLowerCase() : n);
|
||
const isTaken = (n: string) => hasName(n) || (isUnityAssetFolder(target) && hasName(`${n}.meta`));
|
||
const touched = new Set<string>([target]);
|
||
const errors: string[] = [];
|
||
let first: string | null = null;
|
||
const newName = name;
|
||
const upload = source === "local" && isRemoteWindow() ? beginLocalImport() : undefined;
|
||
try {
|
||
for (const src of paths) {
|
||
if (upload?.cancelled) break;
|
||
const name = nameOf(src);
|
||
if (
|
||
!(source === "local" && isRemoteWindow()) &&
|
||
isSameOrInside(target, src) &&
|
||
target !== parentOf(src) &&
|
||
src !== target
|
||
) {
|
||
errors.push(`${name}: cannot copy a folder into itself`);
|
||
continue;
|
||
}
|
||
const srcIsDir =
|
||
get().listings.has(src) || currentModel().byKey.get(currentModel().keyOfPath.get(src) ?? src)?.dir === true;
|
||
const finalName = paths.length === 1 && newName ? newName : uniqueName(name, isTaken, srcIsDir);
|
||
const to = joinPath(target, finalName);
|
||
try {
|
||
if (source === "local") {
|
||
const skipped = await fs.importLocal(src, to, upload);
|
||
errors.push(...skipped);
|
||
} else await fs.copy(src, to);
|
||
taken.add(caseInsensitive ? finalName.toLowerCase() : finalName);
|
||
first ??= to;
|
||
} catch (e) {
|
||
if (isCancelled(e)) break;
|
||
errors.push(`${name}: ${errorMessage(e)}`);
|
||
}
|
||
}
|
||
} finally {
|
||
upload?.finish();
|
||
}
|
||
if (errors.length)
|
||
notifications().notify({ level: "error", title: "Some items could not be copied", body: errors.join("\n") });
|
||
await Promise.all([...touched].map((d) => loadDir(d, true)));
|
||
if (first) {
|
||
expandFolder(target);
|
||
revealPath(first, false);
|
||
}
|
||
return first ?? false;
|
||
}
|
||
|
||
/**
|
||
* Moves `paths` into `target` as one Move refactoring (references updated with
|
||
* the options last used in the Move dialog). Refuses to overwrite.
|
||
*/
|
||
async function moveInto(paths: string[], target: string): Promise<boolean> {
|
||
paths = assetPaths(paths);
|
||
await loadDir(target);
|
||
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
|
||
const errors: string[] = [];
|
||
const moves: { from: string; to: string }[] = [];
|
||
for (const src of paths) {
|
||
const name = nameOf(src);
|
||
if (parentOf(src) === target) continue;
|
||
if (isSameOrInside(target, src)) errors.push(`${name}: cannot move a folder into itself`);
|
||
else if (taken.has(caseInsensitive ? name.toLowerCase() : name))
|
||
errors.push(`${name}: already exists in ${nameOf(target) || target}`);
|
||
else moves.push({ from: src, to: joinPath(target, name) });
|
||
}
|
||
if (errors.length)
|
||
notifications().notify({ level: "error", title: "Some items could not be moved", body: errors.join("\n") });
|
||
if (!moves.length) return false;
|
||
const status = await runFileRefactoring(moves, rememberedOptions("move"), "Move");
|
||
if (status !== "done") return false;
|
||
await afterMove(moves);
|
||
revealPath(moves[0]!.to, false);
|
||
return true;
|
||
}
|
||
|
||
/** Drag & drop inside the tree: the Move dialog (or a confirmed copy with ⌥). */
|
||
export async function dropPaths(paths: string[], target: string, copy: boolean) {
|
||
if (isReadOnlyPath(target)) return;
|
||
const movable = topLevelPaths(
|
||
paths
|
||
.map((p) => currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p))
|
||
.filter((n): n is ProjectNode => !!n && (copy || !n.readOnly)),
|
||
).filter((p) => copy || parentOf(p) !== target);
|
||
if (!movable.length) return;
|
||
// IDEA opens the Move refactoring dialog for drops inside the project.
|
||
if (!copy) {
|
||
const node =
|
||
movable.length === 1 ? currentModel().byKey.get(currentModel().keyOfPath.get(movable[0]!) ?? "") : undefined;
|
||
return openMoveDialogFor(movable, target, node?.dir);
|
||
}
|
||
const node =
|
||
movable.length === 1 ? currentModel().byKey.get(currentModel().keyOfPath.get(movable[0]!) ?? "") : undefined;
|
||
openCopyDialogFor(movable, target, node?.dir);
|
||
}
|
||
|
||
/** Files dropped from the OS: copied into `target` (never moved). */
|
||
export async function importExternal(paths: string[], target: string) {
|
||
const { root } = get();
|
||
if (!root || !paths.length || isReadOnlyPath(target)) return;
|
||
if (isRemoteWindow()) return void (await transfer(paths, target, "copy", undefined, "local"));
|
||
const inside = paths.filter((p) => isSameOrInside(p, root));
|
||
const outside = paths.filter((p) => !isSameOrInside(p, root));
|
||
if (inside.length) await dropPaths(inside, target, false);
|
||
if (outside.length) await transfer(outside, target, "copy", undefined, "local");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Paths / external
|
||
|
||
export type PathKind = "absolute" | "relative" | "name";
|
||
|
||
export function pathsFor(kind: PathKind, nodes: ProjectNode[] = selectedNodes()): string[] {
|
||
const { root } = get();
|
||
return nodes.map((n) =>
|
||
kind === "absolute" ? n.path : kind === "name" ? nameOf(n.path) : relativeTo(root ?? "", n.path) || ".",
|
||
);
|
||
}
|
||
|
||
export async function copyPaths(kind: PathKind, nodes: ProjectNode[] = selectedNodes()) {
|
||
const text = pathsFor(kind, nodes).join("\n");
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch (e) {
|
||
fail(tx("Cannot access the clipboard"), e);
|
||
}
|
||
}
|
||
|
||
export async function revealInFinder(nodes: ProjectNode[] = selectedNodes()) {
|
||
const paths = nodes.map((n) => n.path);
|
||
if (!paths.length) return;
|
||
try {
|
||
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||
await revealItemInDir(paths.length === 1 ? paths[0]! : paths);
|
||
} catch (e) {
|
||
fail("Cannot reveal in Finder", e);
|
||
}
|
||
}
|
||
|
||
export function folderOf(node: ProjectNode | null = leadNode()): string | null {
|
||
return targetFolder(node);
|
||
}
|
||
|
||
/** Runs another feature's action if it is registered; returns whether it exists. */
|
||
export function delegate(id: string, arg: unknown): boolean {
|
||
if (!getAction(id)) return false;
|
||
void executeAction(id, arg);
|
||
return true;
|
||
}
|
||
|
||
/** Argument for VCS actions: one path or several. */
|
||
export function vcsArg(nodes: ProjectNode[] = selectedNodes()): string | string[] {
|
||
const paths = nodes.map((n) => n.path);
|
||
return paths.length === 1 ? paths[0]! : paths;
|
||
}
|