mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: open a download's folder with e (#51)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8673f744f7
commit
6c4289ea10
@@ -98,6 +98,7 @@ function makeStore(
|
||||
setSeedFocus: noop,
|
||||
startDownload: noop,
|
||||
copyMagnet: noop,
|
||||
openDownloadFolder: noop,
|
||||
notice: null,
|
||||
setNotice: noop,
|
||||
quitAll: noop,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user