feat: open a download's folder with e (#51)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Amal Biju
2026-07-05 10:07:26 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 8673f744f7
commit 6c4289ea10
8 changed files with 169 additions and 8 deletions
+1
View File
@@ -98,6 +98,7 @@ function makeStore(
setSeedFocus: noop,
startDownload: noop,
copyMagnet: noop,
openDownloadFolder: noop,
notice: null,
setNotice: noop,
quitAll: noop,
+14
View File
@@ -10,6 +10,7 @@ import { reconcileQueue } from "../download/reconcile";
import { parseInput } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard, writeClipboard } from "../util/clipboard";
import { openFolder } from "../util/openFolder";
import { cleanText, truncate } from "../util/format";
import {
StoreContext,
@@ -236,6 +237,17 @@ export function App({
})();
}, []);
const openDownloadFolder = useCallback((dir: string) => {
void (async () => {
const ok = await openFolder(dir);
if (ok) {
setNotice(`Opened: ${truncate(dir, 48)}`);
return;
}
setNotice(`Couldn't open folder: ${truncate(dir, 48)}`);
})();
}, []);
const submitQuery = useCallback(
(raw: string) => {
const q = raw.trim();
@@ -316,6 +328,7 @@ export function App({
setSeedFocus,
startDownload,
copyMagnet,
openDownloadFolder,
notice,
setNotice,
quitAll,
@@ -341,6 +354,7 @@ export function App({
seedFocus,
startDownload,
copyMagnet,
openDownloadFolder,
notice,
listRows,
compact,
+6 -2
View File
@@ -44,7 +44,8 @@ function rightStats(it: QueueItem): string {
}
export function Downloads() {
const { queue, region, contentWidth, listRows, startDownload, setDownloadFocus } = useStore();
const { queue, region, contentWidth, listRows, startDownload, openDownloadFolder, setDownloadFocus } =
useStore();
const active = useQueueItems(queue);
const recent = useQueueHistory(queue);
const focused = region === "content";
@@ -61,7 +62,10 @@ export function Downloads() {
else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, total));
else if (input === "f") queue.retryFailed();
else if (input === "x") queue.clearHistory();
else if (inActive) {
else if (input === "e") {
const dir = inActive ? active[clamped]?.dir : recent[recentCursor]?.dir;
if (dir) openDownloadFolder(dir);
} else if (inActive) {
const it = active[clamped];
if (!it) return;
if (input === "c") queue.cancel(it.id);
+5 -1
View File
@@ -30,7 +30,8 @@ function statusCell(seed: SeedItem | undefined): { text: string; color?: string;
}
export function Seeding() {
const { queue, region, contentWidth, listRows, setNotice, setSeedFocus } = useStore();
const { queue, region, contentWidth, listRows, setNotice, openDownloadFolder, setSeedFocus } =
useStore();
const history = useQueueHistory(queue);
const seeds = useSeeds(queue);
const focused = region === "content";
@@ -60,6 +61,9 @@ export function Seeding() {
} else if (input === "c") {
const h = history[clamped];
if (h) queue.removeHistory(h.id);
} else if (input === "e") {
const h = history[clamped];
if (h) openDownloadFolder(h.dir);
}
},
{ isActive: focused && total > 0 },
+10 -5
View File
@@ -40,6 +40,7 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "c", label: "Cancel or remove from list" },
{ keys: "f", label: "Retry failed" },
{ keys: "d", label: "Download again" },
{ keys: "e", label: "Open folder" },
{ keys: "x", label: "Clear recent" },
],
},
@@ -48,6 +49,7 @@ export const HELP_GROUPS: HelpGroup[] = [
hints: [
{ keys: "p", label: "Pause/resume" },
{ keys: "c", label: "Remove from list" },
{ keys: "e", label: "Open folder" },
],
},
];
@@ -60,6 +62,8 @@ const ALWAYS: Hint = { keys: "?", label: "Keys" };
const SWITCH: Hint = { keys: "tab", label: "Switch" };
const FOLDER: Hint = { keys: "e", label: "Folder" };
export function footerHints(
region: Region,
section: Section,
@@ -78,26 +82,27 @@ export function footerHints(
if (section === "seeding") {
const label =
seedFocus === "seeding" ? "Pause" : seedFocus === "missing" ? "Retry" : "Resume";
return [{ keys: "p", label }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
return [{ keys: "p", label }, { keys: "c", label: "Remove" }, FOLDER, SWITCH, ALWAYS];
}
if (section === "downloads") {
if (downloadFocus === "paused") {
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, FOLDER, SWITCH, ALWAYS];
}
if (downloadFocus === "failed") {
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, FOLDER, SWITCH, ALWAYS];
}
if (downloadFocus === "recent") {
return [
NAVIGATE,
{ keys: "d", label: "Download again" },
{ keys: "d", label: "Redownload" },
{ keys: "c", label: "Remove" },
{ keys: "x", label: "Clear" },
FOLDER,
SWITCH,
ALWAYS,
];
}
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, FOLDER, SWITCH, ALWAYS];
}
return [
NAVIGATE,
+1
View File
@@ -57,6 +57,7 @@ export interface Store {
sizeBytes?: number;
}) => void;
copyMagnet: (input: { name: string; magnet: string }) => void;
openDownloadFolder: (dir: string) => void;
notice: string | null;
setNotice: (s: string | null) => void;
+75
View File
@@ -0,0 +1,75 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
const spawn = vi.fn();
const existsSync = vi.fn();
vi.mock("node:child_process", () => ({ spawn }));
vi.mock("node:fs", () => ({ existsSync }));
type FakeProc = EventEmitter & { kill: () => void };
function fakeProc(code: number): FakeProc {
const proc = new EventEmitter() as FakeProc;
proc.kill = vi.fn();
queueMicrotask(() => proc.emit("close", code));
return proc;
}
function onPlatform(platform: string): () => void {
const original = process.platform;
Object.defineProperty(process, "platform", { value: platform });
return () => {
Object.defineProperty(process, "platform", { value: original });
vi.resetModules();
spawn.mockReset();
existsSync.mockReset();
};
}
describe("openFolder", () => {
it("falls back to the next Linux opener when the first fails", async () => {
const restore = onPlatform("linux");
try {
existsSync.mockReturnValue(true);
spawn.mockImplementation((cmd: string) => fakeProc(cmd === "gio" ? 0 : 1));
const { openFolder } = await import("./openFolder");
await expect(openFolder("/home/me/Downloads/torlink")).resolves.toBe(true);
expect(spawn).toHaveBeenCalledWith("xdg-open", ["/home/me/Downloads/torlink"]);
expect(spawn).toHaveBeenCalledWith("gio", ["open", "/home/me/Downloads/torlink"]);
} finally {
restore();
}
});
it("treats explorer's nonzero exit as success on Windows", async () => {
const restore = onPlatform("win32");
try {
existsSync.mockReturnValue(true);
spawn.mockImplementation(() => fakeProc(1));
const { openFolder } = await import("./openFolder");
await expect(openFolder("C:\\Users\\me\\Downloads\\torlink")).resolves.toBe(true);
expect(spawn).toHaveBeenCalledWith("explorer", ["C:\\Users\\me\\Downloads\\torlink"]);
} finally {
restore();
}
});
it("reports failure for a folder that no longer exists, without spawning", async () => {
const restore = onPlatform("win32");
try {
existsSync.mockReturnValue(false);
const { openFolder } = await import("./openFolder");
await expect(openFolder("C:\\gone")).resolves.toBe(false);
expect(spawn).not.toHaveBeenCalled();
} finally {
restore();
}
});
});
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
function launch(cmd: string, args: string[], anyExit = false): Promise<boolean> {
return new Promise((resolve) => {
try {
// Unlike clipboard.ts, no windowsHide here: it maps to SW_HIDE in the
// startup info, and explorer.exe honors that for the folder window
// itself — the window would open invisible.
const proc = spawn(cmd, args);
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const done = (ok: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(ok);
};
timer = setTimeout(() => {
try {
proc.kill();
} catch {}
done(false);
}, 4000);
timer.unref?.();
proc.on("error", () => done(false));
proc.on("close", (code) => done(anyExit || code === 0));
} catch {
resolve(false);
}
});
}
const LINUX_OPEN: [string, string[]][] = [
["xdg-open", []],
["gio", ["open"]],
];
// Open `dir` in the platform file manager. Never throws; false means the
// caller should tell the user it didn't work.
export async function openFolder(dir: string): Promise<boolean> {
// Check the path ourselves first: explorer.exe silently opens Documents for a
// path that doesn't exist, which would look like success.
if (!dir || !existsSync(dir)) return false;
if (process.platform === "win32") {
// explorer.exe exits 1 even when the window opens fine, so any clean exit
// counts; only a failure to spawn (or a hang) is a real error.
return launch("explorer", [dir], true);
}
if (process.platform === "darwin") {
return launch("open", [dir]);
}
for (const [cmd, args] of LINUX_OPEN) {
if (await launch(cmd, [...args, dir])) return true;
}
return false;
}