Skip to content

Commit

Permalink
improve hidden API
Browse files Browse the repository at this point in the history
  • Loading branch information
mceachen committed Nov 30, 2024
1 parent 4aa3719 commit 97fc512
Show file tree
Hide file tree
Showing 8 changed files with 440 additions and 64 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"passno",
"preprebuildify",
"tsup",
"unhiding",
"vfstype",
"xarts"
],
Expand Down
226 changes: 220 additions & 6 deletions src/__tests__/hidden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { execSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { statAsync } from "../fs_promises.js";
import { isHidden, isHiddenRecursive, setHidden } from "../index.js";
import { createHiddenPosixPath, LocalSupport } from "../hidden.js";
import {
getHiddenMetadata,
isHidden,
isHiddenRecursive,
setHidden,
} from "../index.js";
import { isWindows } from "../platform.js";
import { validateHidden } from "../test-utils/hidden-tests.js";
import { systemDrive, tmpDirNotHidden } from "../test-utils/platform.js";
Expand Down Expand Up @@ -94,7 +100,7 @@ describe("hidden file tests", () => {

let level2 = path.join(level1, "level2");
await fs.mkdir(level2);
level2 = await setHidden(level2, true);
level2 = (await setHidden(level2, true)).pathname;
const level3 = path.join(level2, "level3");
await fs.mkdir(level3);

Expand Down Expand Up @@ -129,7 +135,11 @@ describe("hidden file tests", () => {
? testFile
: path.join(tempDir, ".to-hide.txt");

expect(await setHidden(testFile, true)).toEqual(expected);
expect(await setHidden(testFile, true)).toEqual(
expect.objectContaining({
pathname: expected,
}),
);
expect(await isHidden(expected)).toBe(true);

expect((await statAsync(expected)).isFile()).toBe(true);
Expand All @@ -143,23 +153,227 @@ describe("hidden file tests", () => {
const expectedHidden = isWindows
? testFile
: path.join(tempDir, ".to-unhide.txt");
const hidden = await setHidden(testFile, true);
const hidden = (await setHidden(testFile, true)).pathname;

expect(hidden).toEqual(expectedHidden);
expect(await isHidden(hidden)).toBe(true);

expect(await setHidden(testFile, false)).toEqual(testFile);
expect(await setHidden(testFile, false)).toEqual(
expect.objectContaining({
pathname: testFile,
}),
);
expect(await isHidden(testFile)).toBe(false);
});

it("should set directory as hidden", async () => {
const testSubDir = path.join(tempDir, "hide-me");
const expected = isWindows ? testSubDir : path.join(tempDir, ".hide-me");
await fs.mkdir(testSubDir);
const hidden = await setHidden(testSubDir, true);
const hidden = (await setHidden(testSubDir, true)).pathname;
expect(hidden).toEqual(expected);
expect(await isHidden(hidden)).toBe(true);
expect((await statAsync(hidden)).isDirectory()).toBe(true);
});
});

describe("getHiddenMetadata()", () => {
if (isWindows) {
it("should return correct metadata for normal file on Windows", async () => {
const testFile = path.join(tempDir, "normal.txt");
await fs.writeFile(testFile, "test");

const metadata = await getHiddenMetadata(testFile);
expect(metadata).toEqual({
hidden: false,
dotPrefix: false,
systemFlag: false,
supported: {
dotPrefix: false,
systemFlag: true,
},
});
});

it("should return correct metadata for hidden file on Windows", async () => {
const testFile = path.join(tempDir, "hidden.txt");
await fs.writeFile(testFile, "test");
execSync(`attrib +h "${testFile}"`);

const metadata = await getHiddenMetadata(testFile);
expect(metadata).toEqual({
hidden: true,
dotPrefix: false,
systemFlag: true,
supported: {
dotPrefix: false,
systemFlag: true,
},
});
});

it("should handle dot-prefixed files correctly on Windows", async () => {
const testFile = path.join(tempDir, ".config");
await fs.writeFile(testFile, "test");

const metadata = await getHiddenMetadata(testFile);
expect(metadata).toEqual({
hidden: false,
dotPrefix: false,
systemFlag: false,
supported: {
dotPrefix: false,
systemFlag: true,
},
});
});
} else {
it("should return correct metadata for normal file on POSIX", async () => {
const testFile = path.join(tempDir, "normal.txt");
await fs.writeFile(testFile, "test");

const metadata = await getHiddenMetadata(testFile);
expect(metadata).toEqual({
hidden: false,
dotPrefix: false,
systemFlag: false,
supported: {
dotPrefix: true,
systemFlag: process.platform === "darwin",
},
});
});

it("should return correct metadata for dot-prefixed file on POSIX", async () => {
const testFile = path.join(tempDir, ".hidden");
await fs.writeFile(testFile, "test");

const metadata = await getHiddenMetadata(testFile);
expect(metadata).toEqual({
hidden: true,
dotPrefix: true,
systemFlag: false,
supported: {
dotPrefix: true,
systemFlag: process.platform === "darwin",
},
});
});
}

it("should handle root directory", async () => {
const metadata = await getHiddenMetadata(systemDrive());
expect(metadata.hidden).toBe(false);
expect(metadata.dotPrefix).toBe(false);
if (isWindows) {
expect(metadata.supported).toEqual({
dotPrefix: false,
systemFlag: true,
});
}
});

it("should handle non-existent paths", async () => {
const nonExistentPath = path.join(tempDir, "does-not-exist");
const metadata = await getHiddenMetadata(nonExistentPath);
expect(metadata.hidden).toBe(false);
expect(metadata.dotPrefix).toBe(false);
expect(metadata.systemFlag).toBe(false);
});
});

describe("setHidden method handling", () => {
it("should respect method parameter", async () => {
const testFile = path.join(tempDir, "method-test.txt");
await fs.writeFile(testFile, "test");

// Test explicit dotPrefix method
const dotPrefixResult = await setHidden(testFile, true, "dotPrefix");

expect(dotPrefixResult.actions).toEqual({
dotPrefix: !isWindows, // true on POSIX, false on Windows
systemFlag: false,
});

// Test explicit systemFlag method
const systemFlagResult = await setHidden(testFile, true, "systemFlag");

expect(systemFlagResult.actions).toEqual({
dotPrefix: false,
systemFlag: isWindows || process.platform === "darwin",
});

// Test "all" method
const allResult = await setHidden(testFile, true, "all");

if (isWindows) {
expect(allResult.actions).toEqual({
dotPrefix: false,
systemFlag: true,
});
} else {
expect(allResult.actions).toEqual({
dotPrefix: true,
systemFlag: process.platform === "darwin",
});
}
});

it("should handle 'auto' method correctly", async () => {
const testFile = path.join(tempDir, "auto-test.txt");
await fs.writeFile(testFile, "test");

const result = await setHidden(testFile, true, "auto");

if (isWindows) {
expect(result.actions).toEqual({
dotPrefix: false,
systemFlag: true,
});
} else {
expect(result.actions).toEqual({
dotPrefix: true,
systemFlag: false, // On POSIX, dotPrefix handles it so systemFlag isn't needed
});
}
});

it("should not apply systemFlag if already handled by dotPrefix in auto mode", async () => {
if (!isWindows) {
const testFile = path.join(tempDir, "already-handled.txt");
await fs.writeFile(testFile, "test");

const mockSetHidden = jest.fn();
const result = await setHidden(testFile, true, "auto");

expect(mockSetHidden).not.toHaveBeenCalled();
expect(result.actions.dotPrefix).toBe(true);
expect(result.actions.systemFlag).toBe(false);
}
});

it("should apply both methods when hiding using 'all'", async () => {
const testFile = path.join(tempDir, "all-methods.txt");
await fs.writeFile(testFile, "test");
const result = await setHidden(testFile, true, "all");
expect(result).toEqual({
pathname: LocalSupport.dotPrefix
? createHiddenPosixPath(testFile, true)
: testFile,
actions: LocalSupport,
});
});

it("should apply both methods when unhiding using 'all'", async () => {
const testFile = path.join(tempDir, ".all-methods.txt");
await fs.writeFile(testFile, "test");
const result = await setHidden(testFile, false, "all");
expect(result).toEqual({
pathname: LocalSupport.dotPrefix
? createHiddenPosixPath(testFile, false)
: testFile,
actions: LocalSupport,
});
});
});
});
45 changes: 33 additions & 12 deletions src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { thenOrTimeout } from "./async.js";
import { filterMountPoints, filterTypedMountPoints } from "./config_filters.js";
import { defer } from "./defer.js";
import { findAncestorDir } from "./fs.js";
import { isHidden, isHiddenRecursive, setHidden } from "./hidden.js";
import {
getHiddenMetadata,
HiddenMetadata,
HideMethod,
isHidden,
isHiddenRecursive,
setHidden,
} from "./hidden.js";
import { getLinuxMountPoints } from "./linux/mount_points.js";
import type { NativeBindings } from "./native_bindings.js";
import { type Options, optionsWithDefaults } from "./options.js";
Expand All @@ -13,7 +20,8 @@ import { getVolumeMetadata, type VolumeMetadata } from "./volume_metadata.js";

/**
* Glue code between the native bindings and the rest of the library to make
* things simpler for index.ts and index.cts
* things simpler for index.ts and index.cts with the management of the native
* bindings.
*/
export class ExportsImpl {
constructor(readonly _dirname: string) {}
Expand Down Expand Up @@ -97,26 +105,39 @@ export class ExportsImpl {
* Note that `path` may be _effectively_ hidden if any of the ancestor
* directories are hidden: use {@link isHiddenRecursive} to check for this.
*
* @param path Path to file or directory
* @param pathname Path to file or directory
* @returns Promise resolving to boolean indicating hidden state
*/
readonly isHidden = (path: string): Promise<boolean> =>
isHidden(path, this.#nativeFn);
readonly isHidden = (pathname: string): Promise<boolean> =>
isHidden(pathname, this.#nativeFn);

/**
* Check if a file or directory is hidden, or if any of its ancestor
* directories are hidden.
*/
readonly isHiddenRecursive = (path: string): Promise<boolean> =>
isHiddenRecursive(path, this.#nativeFn);
readonly isHiddenRecursive = (pathname: string): Promise<boolean> =>
isHiddenRecursive(pathname, this.#nativeFn);

readonly getHiddenMetadata = (pathname: string): Promise<HiddenMetadata> =>
getHiddenMetadata(pathname, this.#nativeFn);

/**
* Set the hidden state of a file or directory
* @param path Path to file or directory
* @param hidden Desired hidden state
*
* @param pathname Path to file or directory
* @param hidden - Whether the item should be hidden (true) or visible (false)
* @param method Method to use for hiding the file or directory. The default
* is "auto", which is "dotPrefix" on Linux and macOS, and "systemFlag" on
* Windows. "all" will attempt to use all relevant methods for the current
* operating system.
* @returns Promise resolving the final name of the file or directory (as it
* will change on POSIX systems)
* will change on POSIX systems), and the action(s) taken.
* @throws {Error} If the file doesn't exist, permissions are insufficient, or
* the requested method is unsupported
*/
readonly setHidden = (path: string, hidden: boolean): Promise<string> =>
setHidden(path, hidden, this.#nativeFn);
readonly setHidden = (
pathname: string,
hidden: boolean,
method: HideMethod = "auto",
) => setHidden(pathname, hidden, method, this.#nativeFn);
}
9 changes: 9 additions & 0 deletions src/fs_promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,12 @@ export async function statAsync(
): Promise<Stats> {
return stat(path, options);
}

export async function canStatAsync(path: string): Promise<boolean> {
try {
await statAsync(path);
return true;
} catch {
return false;
}
}
Loading

0 comments on commit 97fc512

Please sign in to comment.