Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ npm run dev

```
electron/ Electron main process
main.ts Window, IPC handlers, folder scanning, update check
main.ts Window lifecycle, wires IPC handlers to the modules below
file-operations.ts Folder scanning, trash, file stats/reads
settings-store.ts Settings persistence
updater.ts Auto-updater orchestration, its file logger, release page
ipc-types.ts Shared IPC contract (types + channel names)
ipc-register.ts Wires IPC handlers to their channel names
preload.ts contextBridge API exposed to the renderer
src/ React renderer
components/ UI components
Expand Down Expand Up @@ -65,9 +70,9 @@ npm test # once
npm run test:watch # while developing
```

Coverage is partial by design — the suite currently covers file-type classification, the formatting helpers, and the update notifier. Extending it is tracked in [#9](https://github.com/killerwolf/QuickToss/issues/9); good next targets are the undo stack and settings persistence.
Coverage is partial by design — the suite currently covers file-type classification, the formatting helpers, file operations, settings persistence, and the update notifier. Extending it is tracked in [#9](https://github.com/killerwolf/QuickToss/issues/9); the undo stack (`src/App.tsx`) is next.

Logic worth testing should live outside `electron/main.ts`, which instantiates the app at import time and can't be loaded from a test. `electron/file-types.ts` is the pattern to follow: pure functions the main process calls, importable on their own.
Logic worth testing should live outside `electron/main.ts`, which instantiates the app at import time and can't be loaded from a test. `electron/file-types.ts`, `electron/file-operations.ts`, and `electron/settings-store.ts` are the pattern to follow: pure functions (or a factory taking its dependencies as parameters) the main process calls, importable on their own.

Note that `tsconfig.main.json` excludes `*.test.ts` so tests never end up in the packaged app; `tsconfig.test.json` typechecks them instead.

Expand Down
97 changes: 97 additions & 0 deletions electron/file-operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { fileExists, getFileStats, readFileAsBuffer, scanFolder } from "./file-operations";

let dir: string;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "quicktoss-file-operations-"));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

describe("scanFolder", () => {
it("returns supported files, sorted newest-modified first", async () => {
await writeFile(join(dir, "older.txt"), "older");
await utimes(join(dir, "older.txt"), new Date("2024-01-01"), new Date("2024-01-01"));
await writeFile(join(dir, "newer.png"), "newer");
await utimes(join(dir, "newer.png"), new Date("2024-06-01"), new Date("2024-06-01"));

const files = await scanFolder(dir);

expect(files.map((f) => f.name)).toEqual(["newer.png", "older.txt"]);
expect(files[0].type).toBe("image");
expect(files[1].type).toBe("document");
});

it("filters out unsupported extensions", async () => {
await writeFile(join(dir, "supported.jpg"), "x");
await writeFile(join(dir, "unsupported.exe"), "x");

const files = await scanFolder(dir);

expect(files.map((f) => f.name)).toEqual(["supported.jpg"]);
});

it("filters out subdirectories", async () => {
await mkdir(join(dir, "a-folder.pdf"));
await writeFile(join(dir, "a-file.pdf"), "x");

const files = await scanFolder(dir);

expect(files.map((f) => f.name)).toEqual(["a-file.pdf"]);
});

it("rejects when the folder does not exist", async () => {
await expect(scanFolder(join(dir, "does-not-exist"))).rejects.toThrow();
});
});

describe("getFileStats", () => {
it("returns size, modified, and created for an existing file", async () => {
const path = join(dir, "file.txt");
await writeFile(path, "hello");

const stats = await getFileStats(path);

expect(stats?.size).toBe(5);
expect(stats?.modified).toBeInstanceOf(Date);
expect(stats?.created).toBeInstanceOf(Date);
});

it("returns null for a file that does not exist", async () => {
const stats = await getFileStats(join(dir, "missing.txt"));
expect(stats).toBeNull();
});
});

describe("fileExists", () => {
it("is true for an existing file", async () => {
const path = join(dir, "file.txt");
await writeFile(path, "hello");
expect(await fileExists(path)).toBe(true);
});

it("is false for a missing file", async () => {
expect(await fileExists(join(dir, "missing.txt"))).toBe(false);
});
});

describe("readFileAsBuffer", () => {
it("returns the file's bytes as an ArrayBuffer", async () => {
const path = join(dir, "file.txt");
await writeFile(path, "hello");

const buffer = await readFileAsBuffer(path);

expect(Buffer.from(buffer).toString("utf8")).toBe("hello");
});

it("rejects for a file that does not exist", async () => {
await expect(readFileAsBuffer(join(dir, "missing.txt"))).rejects.toThrow();
});
});
93 changes: 93 additions & 0 deletions electron/file-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { shell } from "electron";
import { existsSync, readFileSync } from "fs";
import { readdir, stat } from "fs/promises";
import { join } from "path";
import { getFileType, isSupportedExtension } from "./file-types";
import type { FileItem, FileStats } from "./ipc-types";

// Error-handling policy: operations with no safe fallback value (scanFolder,
// moveToTrash, readFileAsBuffer) log and rethrow, via logAndRethrow below, so
// a failure never gets silently swallowed. getFileStats has a safe fallback
// (null — the file just has no displayable stats) and returns it instead.
// fileExists can't meaningfully fail.
async function logAndRethrow<T>(label: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
console.error(`Error ${label}:`, error);
throw error;
}
}

// Scan a folder for files
export async function scanFolder(folderPath: string): Promise<FileItem[]> {
return logAndRethrow("scanning folder", async () => {
const files: FileItem[] = [];
const entries = await readdir(folderPath);

for (const entry of entries) {
const fullPath = join(folderPath, entry);
const stats = await stat(fullPath);

if (stats.isFile()) {
const extension = entry.toLowerCase().substring(entry.lastIndexOf("."));

if (isSupportedExtension(extension)) {
files.push({
name: entry,
path: fullPath,
size: stats.size,
modified: stats.mtime,
extension,
type: getFileType(extension),
});
}
}
}

// Sort by modification date (newest first)
files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());

return files;
});
}

// Move a file to trash.
//
// Not covered by file-operations.test.ts: shell.trashItem doesn't behave
// meaningfully outside a real Electron main process, so a temp-dir test can
// only prove a mock got called, not that a file actually moved to trash.
export async function moveToTrash(filePath: string): Promise<boolean> {
return logAndRethrow("moving to trash", async () => {
await shell.trashItem(filePath);
return true;
});
}

// Get file stats
export async function getFileStats(filePath: string): Promise<FileStats | null> {
try {
const stats = await stat(filePath);
return {
size: stats.size,
modified: stats.mtime,
created: stats.birthtime,
};
} catch (error) {
console.error("Error getting file stats:", error);
return null;
}
}

// Check if file exists
export async function fileExists(filePath: string): Promise<boolean> {
return existsSync(filePath);
}

// Read file as buffer for PDF preview
export async function readFileAsBuffer(filePath: string): Promise<ArrayBuffer> {
return logAndRethrow("reading file as buffer", async () => {
const buffer = readFileSync(filePath);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
});
}
Loading