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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ npm test # once
npm run test:watch # while developing
```

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.
Coverage is partial by design — the suite currently covers file-type classification, the formatting helpers, file operations, settings persistence, session state (`src/hooks/useFileSession.ts` — keep/delete/undo), and the update notifier. Extending it is tracked in [#9](https://github.com/killerwolf/QuickToss/issues/9).

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.

Expand Down
104 changes: 21 additions & 83 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import CompletionScreen from "./components/CompletionScreen";
import FileViewer from "./components/FileViewer";
import UpdateNotifier from "./components/UpdateNotifier";
import WelcomeScreen from "./components/WelcomeScreen";
import type { SessionState } from "./types";
import { useFileSession } from "./hooks/useFileSession";
import "./electron.d.ts";

type AppState = "welcome" | "viewing" | "completed";

function App() {
const [appState, setAppState] = useState<AppState>("welcome");
const [sessionState, setSessionState] = useState<SessionState>({
files: [],
currentIndex: 0,
deletedFiles: [],
keptFiles: [],
folderPath: "",
undoStack: [],
});
const { sessionState, isComplete, startSession, keep, deleteFile, undo, reset } =
useFileSession();

// Moves to the completion screen once the session actually reports itself
// done, rather than the caller re-deriving that from currentIndex/files.
useEffect(() => {
if (isComplete && appState === "viewing") {
setAppState("completed");
}
}, [isComplete, appState]);

const handleFolderSelected = async (folderPath: string) => {
try {
Expand All @@ -28,81 +30,24 @@ function App() {
return;
}

setSessionState({
files,
currentIndex: 0,
deletedFiles: [],
keptFiles: [],
folderPath,
undoStack: [],
});
startSession(files, folderPath);
setAppState("viewing");
} catch (error) {
console.error("Error scanning folder:", error);
alert("Error scanning folder. Please try again.");
}
};

const handleFileAction = (action: "delete" | "keep", fileIndex: number) => {
const file = sessionState.files[fileIndex];

setSessionState((prev) => {
const newState = { ...prev };

// Add to undo stack
newState.undoStack.push({
action,
fileIndex,
file,
});

// Update appropriate array
if (action === "delete") {
newState.deletedFiles.push(file);
// Actually move file to trash
window.electronAPI.moveToTrash(file.path).catch(console.error);
} else {
newState.keptFiles.push(file);
}

// Move to next file
newState.currentIndex = fileIndex + 1;

return newState;
});

// Check if we've processed all files
if (fileIndex + 1 >= sessionState.files.length) {
setAppState("completed");
const handleFileAction = async (action: "delete" | "keep") => {
if (action === "delete") {
await deleteFile();
} else {
keep();
}
};

const handleUndo = async () => {
if (sessionState.undoStack.length === 0) return;

const lastAction = sessionState.undoStack[sessionState.undoStack.length - 1];

setSessionState((prev) => {
const newState = { ...prev };

// Remove from undo stack
newState.undoStack.pop();

// Reverse the action
if (lastAction.action === "delete") {
newState.deletedFiles = newState.deletedFiles.filter(
(f) => f.path !== lastAction.file.path
);
// Note: We can't restore from trash automatically, but we remove from deleted list
} else {
newState.keptFiles = newState.keptFiles.filter((f) => f.path !== lastAction.file.path);
}

// Go back to previous file
newState.currentIndex = lastAction.fileIndex;

return newState;
});
const handleUndo = () => {
undo();

// If we undid from completion screen, go back to viewing
if (appState === "completed") {
Expand All @@ -111,14 +56,7 @@ function App() {
};

const handleStartOver = () => {
setSessionState({
files: [],
currentIndex: 0,
deletedFiles: [],
keptFiles: [],
folderPath: "",
undoStack: [],
});
reset();
setAppState("welcome");
};

Expand Down
15 changes: 11 additions & 4 deletions src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SettingsComponent from "./Settings";

interface FileViewerProps {
sessionState: SessionState;
onFileAction: (action: "delete" | "keep", fileIndex: number) => void;
onFileAction: (action: "delete" | "keep") => Promise<void>;
onUndo: () => void;
onBack: () => void;
}
Expand Down Expand Up @@ -172,12 +172,19 @@ const FileViewer: React.FC<FileViewerProps> = ({ sessionState, onFileAction, onU
playActionSound(action);

// Trigger action after brief delay for feedback
setTimeout(() => {
onFileAction(action, currentIndex);
setTimeout(async () => {
try {
await onFileAction(action);
} catch (error) {
console.error(`Error performing ${action}:`, error);
window.alert(
`Couldn't ${action === "delete" ? "delete" : "keep"} "${currentFile.name}". Please try again.`
);
}
setActionFeedback({ show: false, type: action });
}, 200);
},
[onFileAction, currentIndex, playActionSound, settings.confirmDelete, currentFile.name]
[onFileAction, playActionSound, settings.confirmDelete, currentFile.name]
);

// Keyboard shortcuts
Expand Down
164 changes: 164 additions & 0 deletions src/hooks/useFileSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FileItem } from "../types";
import { useFileSession } from "./useFileSession";

const file = (name: string): FileItem => ({
name,
path: `/folder/${name}`,
size: 100,
modified: new Date("2024-01-01"),
extension: ".txt",
type: "document",
});

let moveToTrash: ReturnType<typeof vi.fn>;

beforeEach(() => {
moveToTrash = vi.fn().mockResolvedValue(true);
window.electronAPI = { moveToTrash } as unknown as typeof window.electronAPI;
});

describe("isComplete", () => {
it("is false for a fresh, never-started session", () => {
const { result } = renderHook(() => useFileSession());
expect(result.current.isComplete).toBe(false);
});

it("is false until the last file is processed, then true", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt"), file("b.txt")], "/folder"));
expect(result.current.isComplete).toBe(false);

act(() => result.current.keep());
expect(result.current.isComplete).toBe(false);

act(() => result.current.keep());
expect(result.current.isComplete).toBe(true);
});

it("goes back to false after undoing the last file", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));
act(() => result.current.keep());
expect(result.current.isComplete).toBe(true);

act(() => result.current.undo());
expect(result.current.isComplete).toBe(false);
});
});

describe("startSession", () => {
it("sets up a fresh session at index 0", () => {
const { result } = renderHook(() => useFileSession());

act(() => {
result.current.startSession([file("a.txt"), file("b.txt")], "/folder");
});

expect(result.current.sessionState.files).toHaveLength(2);
expect(result.current.sessionState.currentIndex).toBe(0);
expect(result.current.sessionState.folderPath).toBe("/folder");
expect(result.current.sessionState.deletedFiles).toEqual([]);
expect(result.current.sessionState.keptFiles).toEqual([]);
expect(result.current.sessionState.undoStack).toEqual([]);
});
});

describe("keep", () => {
it("moves the current file to keptFiles, advances the index, and records an undo entry", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt"), file("b.txt")], "/folder"));

act(() => result.current.keep());

expect(result.current.sessionState.keptFiles.map((f) => f.name)).toEqual(["a.txt"]);
expect(result.current.sessionState.currentIndex).toBe(1);
expect(result.current.sessionState.undoStack).toEqual([
{ action: "keep", fileIndex: 0, file: file("a.txt") },
]);
});
});

describe("deleteFile", () => {
it("moves the file to trash, then commits it to deletedFiles and advances the index", async () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt"), file("b.txt")], "/folder"));

await act(async () => {
await result.current.deleteFile();
});

expect(moveToTrash).toHaveBeenCalledWith("/folder/a.txt");
expect(result.current.sessionState.deletedFiles.map((f) => f.name)).toEqual(["a.txt"]);
expect(result.current.sessionState.currentIndex).toBe(1);
expect(result.current.sessionState.undoStack).toEqual([
{ action: "delete", fileIndex: 0, file: file("a.txt") },
]);
});

it("does not commit the deletion when moveToTrash fails", async () => {
moveToTrash.mockRejectedValue(new Error("permission denied"));
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));

await act(async () => {
await expect(result.current.deleteFile()).rejects.toThrow("permission denied");
});

expect(result.current.sessionState.deletedFiles).toEqual([]);
expect(result.current.sessionState.currentIndex).toBe(0);
expect(result.current.sessionState.undoStack).toEqual([]);
});
});

describe("undo", () => {
it("reverses a keep", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));
act(() => result.current.keep());

act(() => result.current.undo());

expect(result.current.sessionState.keptFiles).toEqual([]);
expect(result.current.sessionState.currentIndex).toBe(0);
expect(result.current.sessionState.undoStack).toEqual([]);
});

it("reverses a delete's bookkeeping without restoring the file from trash", async () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));
await act(async () => {
await result.current.deleteFile();
});

act(() => result.current.undo());

expect(result.current.sessionState.deletedFiles).toEqual([]);
expect(result.current.sessionState.currentIndex).toBe(0);
expect(result.current.sessionState.undoStack).toEqual([]);
});

it("is a no-op when there's nothing to undo", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));

act(() => result.current.undo());

expect(result.current.sessionState.currentIndex).toBe(0);
});
});

describe("reset", () => {
it("clears the session back to empty", () => {
const { result } = renderHook(() => useFileSession());
act(() => result.current.startSession([file("a.txt")], "/folder"));
act(() => result.current.keep());

act(() => result.current.reset());

expect(result.current.sessionState.files).toEqual([]);
expect(result.current.sessionState.folderPath).toBe("");
expect(result.current.sessionState.keptFiles).toEqual([]);
});
});
Loading