feat: added 'Copy magnet' option via 'y' key

This commit is contained in:
Francesco Pira
2026-06-29 18:55:14 +02:00
parent 69027331b2
commit e255e202d1
7 changed files with 134 additions and 3 deletions
+1
View File
@@ -97,6 +97,7 @@ function makeStore(
seedFocus: null,
setSeedFocus: noop,
startDownload: noop,
copyMagnet: noop,
notice: null,
setNotice: noop,
quitAll: noop,
+14 -1
View File
@@ -8,7 +8,7 @@ import { loadHistory } from "../download/history";
import { reconcileQueue } from "../download/reconcile";
import { parseMagnet } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard } from "../util/clipboard";
import { readClipboard, writeClipboard } from "../util/clipboard";
import { cleanText, truncate } from "../util/format";
import {
StoreContext,
@@ -168,6 +168,17 @@ export function App({
[config, queue],
);
const copyMagnet = useCallback((input: { name: string; magnet: string }) => {
void (async () => {
const ok = await writeClipboard(input.magnet);
if (ok) {
setNotice(`Copied magnet: ${truncate(cleanText(input.magnet), 60)}`);
return;
}
setNotice(`Couldn't copy magnet for ${truncate(cleanText(input.name), 32)}.`);
})();
}, []);
const submitQuery = useCallback(
(raw: string) => {
const q = raw.trim();
@@ -247,6 +258,7 @@ export function App({
seedFocus,
setSeedFocus,
startDownload,
copyMagnet,
notice,
setNotice,
quitAll,
@@ -269,6 +281,7 @@ export function App({
downloadFocus,
seedFocus,
startDownload,
copyMagnet,
notice,
listRows,
compact,
+21
View File
@@ -80,6 +80,14 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
</Text>
}
/>
<DetailRow
label="Magnet"
value={
<Text color={COLOR.alt} dimColor wrap="truncate-end">
{r.magnet}
</Text>
}
/>
</Box>
<Box marginTop={1}>
<Text color={COLOR.accent} bold>
@@ -87,6 +95,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
</Text>
<Text color={COLOR.text}> Download</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.accent} bold>
y
</Text>
<Text color={COLOR.text}> Copy magnet</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> back</Text>
</Box>
@@ -102,6 +115,7 @@ export function Results() {
region,
setCaptureMode,
startDownload,
copyMagnet,
contentWidth,
listRows,
} = useStore();
@@ -149,6 +163,9 @@ export function Results() {
sizeBytes: r.sizeBytes,
});
const copyResultMagnet = (r: TorrentResult): void =>
copyMagnet({ name: r.name, magnet: r.magnet });
useInput(
(input, key) => {
if (input === "/") {
@@ -169,6 +186,9 @@ export function Results() {
} else if (input === "d") {
const r = results[clamped];
if (r) openDownload(r);
} else if (input === "y") {
const r = results[clamped];
if (r) copyResultMagnet(r);
}
},
{ isActive: focused && mode === "list" },
@@ -180,6 +200,7 @@ export function Results() {
setMode("list");
setDetail(null);
} else if (input === "d" && detail) openDownload(detail);
else if (input === "y" && detail) copyResultMagnet(detail);
},
{ isActive: focused && mode === "detail" },
);
+2
View File
@@ -26,6 +26,7 @@ export const HELP_GROUPS: HelpGroup[] = [
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "↵", label: "Run search" },
{ keys: "y", label: "Copy magnet" },
{ keys: "m", label: "Paste magnet" },
],
},
@@ -92,6 +93,7 @@ export function footerHints(
}
return [
{ keys: "d", label: "Download" },
{ keys: "y", label: "Copy magnet" },
{ keys: "/", label: "Search" },
{ keys: "m", label: "Paste magnet" },
SWITCH,
+1
View File
@@ -56,6 +56,7 @@ export interface Store {
source?: SourceId;
sizeBytes?: number;
}) => void;
copyMagnet: (input: { name: string; magnet: string }) => void;
notice: string | null;
setNotice: (s: string | null) => void;
+42
View File
@@ -0,0 +1,42 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
const spawn = vi.fn();
vi.mock("node:child_process", () => ({ spawn }));
describe("writeClipboard", () => {
it("writes text to the first available Linux clipboard command", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux" });
try {
spawn.mockImplementation((cmd: string) => {
const proc = new EventEmitter() as EventEmitter & {
stdin: { end: (value: string) => void };
stdout: EventEmitter;
kill: () => void;
};
proc.stdout = new EventEmitter();
proc.kill = vi.fn();
proc.stdin = {
end: vi.fn(() => {
queueMicrotask(() => proc.emit("close", cmd === "wl-copy" ? 0 : 1));
}),
};
return proc;
});
const { writeClipboard } = await import("./clipboard");
await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true);
expect(spawn).toHaveBeenCalledWith("wl-copy", [], { windowsHide: true });
expect(spawn.mock.results[0]?.value.stdin.end).toHaveBeenCalledWith(
"magnet:?xt=urn:btih:abc",
);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
vi.resetModules();
spawn.mockReset();
}
});
});
+53 -2
View File
@@ -27,12 +27,46 @@ function run(cmd: string, args: string[]): Promise<string> {
});
}
const LINUX: [string, string[]][] = [
function write(cmd: string, args: string[], text: string): Promise<boolean> {
return new Promise((resolve) => {
try {
const proc = spawn(cmd, args, { windowsHide: true });
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(code === 0));
proc.stdin?.end(text);
} catch {
resolve(false);
}
});
}
const LINUX_READ: [string, string[]][] = [
["wl-paste", ["--no-newline"]],
["xclip", ["-selection", "clipboard", "-o"]],
["xsel", ["-b"]],
];
const LINUX_WRITE: [string, string[]][] = [
["wl-copy", []],
["xclip", ["-selection", "clipboard"]],
["xsel", ["-b", "-i"]],
];
export async function readClipboard(): Promise<string> {
if (process.platform === "win32") {
return (await run("powershell", ["-NoProfile", "-Command", "Get-Clipboard"])).trim();
@@ -40,9 +74,26 @@ export async function readClipboard(): Promise<string> {
if (process.platform === "darwin") {
return (await run("pbpaste", [])).trim();
}
for (const [cmd, args] of LINUX) {
for (const [cmd, args] of LINUX_READ) {
const out = (await run(cmd, args)).trim();
if (out) return out;
}
return "";
}
export async function writeClipboard(text: string): Promise<boolean> {
if (process.platform === "win32") {
return write(
"powershell",
["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"],
text,
);
}
if (process.platform === "darwin") {
return write("pbcopy", [], text);
}
for (const [cmd, args] of LINUX_WRITE) {
if (await write(cmd, args, text)) return true;
}
return false;
}