Skip to content

Commit 090986a

Browse files
committed
feat(files): skip excluded paths during copy operations
1 parent 36b44d9 commit 090986a

8 files changed

Lines changed: 329 additions & 29 deletions

File tree

‎src/lib/openFolder.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import confirm from "dialogs/confirm";
1010
import prompt from "dialogs/prompt";
1111
import select from "dialogs/select";
1212
import escapeStringRegexp from "escape-string-regexp";
13+
import copyEntry from "utils/copyEntry";
1314
import helpers from "utils/helpers";
1415
import Path from "utils/Path";
1516
import Uri from "utils/Uri";
@@ -776,7 +777,19 @@ function execOperation(type, action, url, $target, name) {
776777
newUrl = await fs.moveTo(url);
777778
}
778779
} else {
779-
newUrl = await fs.copyTo(url);
780+
if (appSettings.value.useFileOperationExclusions) {
781+
const result = await copyEntry(clipBoard.url, url, {
782+
excludePatterns: appSettings.value.excludeFolders,
783+
});
784+
newUrl = result.url;
785+
if (!newUrl) {
786+
toast(strings.skipped);
787+
clearClipboard();
788+
return;
789+
}
790+
} else {
791+
newUrl = await fs.copyTo(url);
792+
}
780793
}
781794
stopLoading();
782795

‎src/lib/settings.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ class Settings {
192192
16, 19, 17, 23, 24, 25, 26, 27, 28, 29, 30, 31,
193193
],
194194
excludeFolders: this.#excludeFolders,
195+
useFileOperationExclusions: true,
195196
defaultFileEncoding: "UTF-8",
196197
inlineAutoCompletion: true,
197198
colorPreview: true,

‎src/pages/fileBrowser/fileBrowser.js‎

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import mimeTypes from "mime-types";
2626
import mustache from "mustache";
2727
import filesSettings from "settings/filesSettings";
2828
import URLParse from "url-parse";
29+
import copyEntry from "utils/copyEntry";
2930
import helpers from "utils/helpers";
3031
import Url from "utils/Url";
3132
import _addMenu from "./add-menu.hbs";
@@ -726,6 +727,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
726727
);
727728

728729
let copiedCount = 0;
730+
let skippedCount = 0;
729731

730732
try {
731733
for (const url of copiedItems) {
@@ -772,48 +774,32 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) {
772774
await fsOperation(possibleConflictUrl).delete();
773775
}
774776

775-
await copyEntry(url, targetDirUrl, name, stat);
776-
copiedCount++;
777+
const result = await copyEntry(url, targetDirUrl, {
778+
name,
779+
stat,
780+
excludePatterns: appSettings.value.useFileOperationExclusions
781+
? appSettings.value.excludeFolders
782+
: [],
783+
});
784+
if (result.url) copiedCount++;
785+
skippedCount += result.skipped;
777786
}
778787
} catch (err) {
779788
helpers.error(err);
780789
} finally {
781790
if (copiedCount) {
782791
toast(strings.success);
783792
reload();
793+
} else if (skippedCount) {
794+
toast(strings.skipped);
784795
}
796+
if (copiedCount || skippedCount) copiedItems = [];
785797
loadingDialog.destroy();
786798
isPasting = false;
787799
updatePasteToggler();
788800
}
789801
}
790802

791-
async function copyEntry(sourceUrl, targetDirUrl, name, sourceStat) {
792-
const fs = fsOperation(sourceUrl);
793-
const stat = sourceStat || (await fs.stat());
794-
const entryName = name || stat.name || Url.basename(sourceUrl);
795-
796-
if (stat.isDirectory) {
797-
const newDirUrl =
798-
await fsOperation(targetDirUrl).createDirectory(entryName);
799-
const entries = await fs.lsDir();
800-
801-
for (const entry of entries) {
802-
await copyEntry(
803-
entry.url,
804-
newDirUrl,
805-
entry.name || Url.basename(entry.url),
806-
entry,
807-
);
808-
}
809-
810-
return newDirUrl;
811-
}
812-
813-
const content = await fs.readFile();
814-
return fsOperation(targetDirUrl).createFile(entryName, content);
815-
}
816-
817803
function isInsideDirectory(sourceUrl, targetUrl) {
818804
let source = Url.parse(sourceUrl).url;
819805
let target = Url.parse(targetUrl).url;

‎src/settings/appSettings.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,17 @@ export default function otherSettings() {
264264
info: strings["settings-info-app-exclude-folders"],
265265
category: categories.filesSessions,
266266
},
267+
{
268+
key: "useFileOperationExclusions",
269+
text:
270+
strings["apply exclusions when copying"] ||
271+
"Apply exclusions when copying",
272+
checkbox: values.useFileOperationExclusions,
273+
info:
274+
strings["settings-info-app-use-file-operation-exclusions"] ||
275+
"Skip files and folders matching the exclusion patterns during copy and paste operations.",
276+
category: categories.filesSessions,
277+
},
267278
{
268279
key: "defaultFileEncoding",
269280
text: strings["default file encoding"],

‎src/utils/copyEntry.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import fsOperation from "fileSystem";
2+
import { isExcludedFileOperationPath } from "./fileOperationExclusions";
3+
import Url from "./Url";
4+
5+
/**
6+
* Recursively copy a file or directory while omitting excluded entries.
7+
*
8+
* @param {string} sourceUrl
9+
* @param {string} targetDirUrl
10+
* @param {object} [options]
11+
* @param {string} [options.name]
12+
* @param {object} [options.stat]
13+
* @param {string[]} [options.excludePatterns]
14+
* @returns {Promise<{url: string|null, copied: number, skipped: number}>}
15+
*/
16+
export default async function copyEntry(
17+
sourceUrl,
18+
targetDirUrl,
19+
{ name, stat: sourceStat, excludePatterns = [] } = {},
20+
) {
21+
if (isExcludedFileOperationPath(sourceUrl, excludePatterns)) {
22+
return { url: null, copied: 0, skipped: 1 };
23+
}
24+
25+
const sourceFs = fsOperation(sourceUrl);
26+
const stat = sourceStat || (await sourceFs.stat());
27+
const entryName = name || stat.name || Url.basename(sourceUrl);
28+
29+
if (!stat.isDirectory) {
30+
const content = await sourceFs.readFile();
31+
const url = await fsOperation(targetDirUrl).createFile(entryName, content);
32+
return { url, copied: 1, skipped: 0 };
33+
}
34+
35+
const url = await fsOperation(targetDirUrl).createDirectory(entryName);
36+
const result = { url, copied: 1, skipped: 0 };
37+
const entries = await sourceFs.lsDir();
38+
39+
for (const entry of entries) {
40+
const child = await copyEntry(entry.url, url, {
41+
name: entry.name || Url.basename(entry.url),
42+
stat: entry,
43+
excludePatterns,
44+
});
45+
result.copied += child.copied;
46+
result.skipped += child.skipped;
47+
}
48+
49+
return result;
50+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import picomatch from "picomatch/posix";
2+
import Url from "utils/Url";
3+
4+
/**
5+
* Test whether a file-system URL matches one of the configured exclusion globs.
6+
* Both the URL path and a directory-style version are checked so recursive
7+
* glob patterns also match the excluded directory itself.
8+
*
9+
* @param {string} url
10+
* @param {string[]} patterns
11+
* @returns {boolean}
12+
*/
13+
export function isExcludedFileOperationPath(url, patterns = []) {
14+
if (!url || !Array.isArray(patterns) || !patterns.length) return false;
15+
16+
const parsedUrl = Url.parse(url).url;
17+
const pathname = Url.pathname(parsedUrl) || parsedUrl;
18+
const normalizedPath = pathname.replace(/\\/g, "/").replace(/\/+$/, "");
19+
const relativePath = normalizedPath.replace(/^\/+/, "");
20+
const candidates = [
21+
normalizedPath,
22+
`${normalizedPath}/`,
23+
relativePath,
24+
`${relativePath}/`,
25+
];
26+
27+
return patterns.some((pattern) => {
28+
if (typeof pattern !== "string" || !pattern.trim()) return false;
29+
30+
try {
31+
return candidates.some((candidate) =>
32+
picomatch.isMatch(candidate, pattern.trim(), {
33+
matchBase: !pattern.includes("/") && !pattern.includes("\\"),
34+
}),
35+
);
36+
} catch (error) {
37+
console.warn(`Invalid file exclusion pattern: ${pattern}`, error);
38+
return false;
39+
}
40+
});
41+
}

‎tests/unit/copyEntry.test.js‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const fileSystem = vi.hoisted(() => {
4+
const entries = new Map([
5+
[
6+
"/source",
7+
{
8+
name: "source",
9+
isDirectory: true,
10+
children: ["/source/src", "/source/node_modules"],
11+
},
12+
],
13+
[
14+
"/source/src",
15+
{
16+
name: "src",
17+
isDirectory: true,
18+
children: ["/source/src/index.js"],
19+
},
20+
],
21+
[
22+
"/source/src/index.js",
23+
{
24+
name: "index.js",
25+
isDirectory: false,
26+
content: "console.log('copied');",
27+
},
28+
],
29+
[
30+
"/source/node_modules",
31+
{
32+
name: "node_modules",
33+
isDirectory: true,
34+
children: ["/source/node_modules/package.json"],
35+
},
36+
],
37+
[
38+
"/source/node_modules/package.json",
39+
{
40+
name: "package.json",
41+
isDirectory: false,
42+
content: "{}",
43+
},
44+
],
45+
]);
46+
47+
return {
48+
entries,
49+
created: [],
50+
};
51+
});
52+
53+
vi.mock("fileSystem", () => ({
54+
default(url) {
55+
const entry = fileSystem.entries.get(url);
56+
57+
return {
58+
async stat() {
59+
return { ...entry, url };
60+
},
61+
async lsDir() {
62+
return entry.children.map((childUrl) => ({
63+
...fileSystem.entries.get(childUrl),
64+
url: childUrl,
65+
}));
66+
},
67+
async readFile() {
68+
return entry.content;
69+
},
70+
async createDirectory(name) {
71+
const createdUrl = `${url}/${name}`;
72+
fileSystem.created.push({ type: "directory", url: createdUrl });
73+
return createdUrl;
74+
},
75+
async createFile(name, content) {
76+
const createdUrl = `${url}/${name}`;
77+
fileSystem.created.push({ type: "file", url: createdUrl, content });
78+
return createdUrl;
79+
},
80+
};
81+
},
82+
}));
83+
84+
import copyEntry from "utils/copyEntry";
85+
86+
describe("copyEntry", () => {
87+
beforeEach(() => {
88+
fileSystem.created.length = 0;
89+
});
90+
91+
it("prunes excluded directory subtrees", async () => {
92+
const result = await copyEntry("/source", "/target", {
93+
excludePatterns: ["**/node_modules/**"],
94+
});
95+
96+
expect(result).toEqual({
97+
url: "/target/source",
98+
copied: 3,
99+
skipped: 1,
100+
});
101+
expect(fileSystem.created).toEqual([
102+
{ type: "directory", url: "/target/source" },
103+
{ type: "directory", url: "/target/source/src" },
104+
{
105+
type: "file",
106+
url: "/target/source/src/index.js",
107+
content: "console.log('copied');",
108+
},
109+
]);
110+
});
111+
112+
it("copies excluded paths when no patterns are enabled", async () => {
113+
const result = await copyEntry("/source/node_modules", "/target");
114+
115+
expect(result.skipped).toBe(0);
116+
expect(fileSystem.created.map(({ url }) => url)).toEqual([
117+
"/target/node_modules",
118+
"/target/node_modules/package.json",
119+
]);
120+
});
121+
});

0 commit comments

Comments
 (0)