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,
|
setSeedFocus: noop,
|
||||||
startDownload: noop,
|
startDownload: noop,
|
||||||
copyMagnet: noop,
|
copyMagnet: noop,
|
||||||
|
openDownloadFolder: noop,
|
||||||
notice: null,
|
notice: null,
|
||||||
setNotice: noop,
|
setNotice: noop,
|
||||||
quitAll: noop,
|
quitAll: noop,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { reconcileQueue } from "../download/reconcile";
|
|||||||
import { parseInput } from "../sources/magnet";
|
import { parseInput } from "../sources/magnet";
|
||||||
import { magnetFromTorrentFile } from "../sources/torrentFile";
|
import { magnetFromTorrentFile } from "../sources/torrentFile";
|
||||||
import { readClipboard, writeClipboard } from "../util/clipboard";
|
import { readClipboard, writeClipboard } from "../util/clipboard";
|
||||||
|
import { openFolder } from "../util/openFolder";
|
||||||
import { cleanText, truncate } from "../util/format";
|
import { cleanText, truncate } from "../util/format";
|
||||||
import {
|
import {
|
||||||
StoreContext,
|
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(
|
const submitQuery = useCallback(
|
||||||
(raw: string) => {
|
(raw: string) => {
|
||||||
const q = raw.trim();
|
const q = raw.trim();
|
||||||
@@ -316,6 +328,7 @@ export function App({
|
|||||||
setSeedFocus,
|
setSeedFocus,
|
||||||
startDownload,
|
startDownload,
|
||||||
copyMagnet,
|
copyMagnet,
|
||||||
|
openDownloadFolder,
|
||||||
notice,
|
notice,
|
||||||
setNotice,
|
setNotice,
|
||||||
quitAll,
|
quitAll,
|
||||||
@@ -341,6 +354,7 @@ export function App({
|
|||||||
seedFocus,
|
seedFocus,
|
||||||
startDownload,
|
startDownload,
|
||||||
copyMagnet,
|
copyMagnet,
|
||||||
|
openDownloadFolder,
|
||||||
notice,
|
notice,
|
||||||
listRows,
|
listRows,
|
||||||
compact,
|
compact,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ function rightStats(it: QueueItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Downloads() {
|
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 active = useQueueItems(queue);
|
||||||
const recent = useQueueHistory(queue);
|
const recent = useQueueHistory(queue);
|
||||||
const focused = region === "content";
|
const focused = region === "content";
|
||||||
@@ -61,7 +62,10 @@ export function Downloads() {
|
|||||||
else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, total));
|
else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, total));
|
||||||
else if (input === "f") queue.retryFailed();
|
else if (input === "f") queue.retryFailed();
|
||||||
else if (input === "x") queue.clearHistory();
|
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];
|
const it = active[clamped];
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
if (input === "c") queue.cancel(it.id);
|
if (input === "c") queue.cancel(it.id);
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ function statusCell(seed: SeedItem | undefined): { text: string; color?: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Seeding() {
|
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 history = useQueueHistory(queue);
|
||||||
const seeds = useSeeds(queue);
|
const seeds = useSeeds(queue);
|
||||||
const focused = region === "content";
|
const focused = region === "content";
|
||||||
@@ -60,6 +61,9 @@ export function Seeding() {
|
|||||||
} else if (input === "c") {
|
} else if (input === "c") {
|
||||||
const h = history[clamped];
|
const h = history[clamped];
|
||||||
if (h) queue.removeHistory(h.id);
|
if (h) queue.removeHistory(h.id);
|
||||||
|
} else if (input === "e") {
|
||||||
|
const h = history[clamped];
|
||||||
|
if (h) openDownloadFolder(h.dir);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: focused && total > 0 },
|
{ isActive: focused && total > 0 },
|
||||||
|
|||||||
+10
-5
@@ -40,6 +40,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|||||||
{ keys: "c", label: "Cancel or remove from list" },
|
{ keys: "c", label: "Cancel or remove from list" },
|
||||||
{ keys: "f", label: "Retry failed" },
|
{ keys: "f", label: "Retry failed" },
|
||||||
{ keys: "d", label: "Download again" },
|
{ keys: "d", label: "Download again" },
|
||||||
|
{ keys: "e", label: "Open folder" },
|
||||||
{ keys: "x", label: "Clear recent" },
|
{ keys: "x", label: "Clear recent" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -48,6 +49,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|||||||
hints: [
|
hints: [
|
||||||
{ keys: "p", label: "Pause/resume" },
|
{ keys: "p", label: "Pause/resume" },
|
||||||
{ keys: "c", label: "Remove from list" },
|
{ 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 SWITCH: Hint = { keys: "tab", label: "Switch" };
|
||||||
|
|
||||||
|
const FOLDER: Hint = { keys: "e", label: "Folder" };
|
||||||
|
|
||||||
export function footerHints(
|
export function footerHints(
|
||||||
region: Region,
|
region: Region,
|
||||||
section: Section,
|
section: Section,
|
||||||
@@ -78,26 +82,27 @@ export function footerHints(
|
|||||||
if (section === "seeding") {
|
if (section === "seeding") {
|
||||||
const label =
|
const label =
|
||||||
seedFocus === "seeding" ? "Pause" : seedFocus === "missing" ? "Retry" : "Resume";
|
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 (section === "downloads") {
|
||||||
if (downloadFocus === "paused") {
|
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") {
|
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") {
|
if (downloadFocus === "recent") {
|
||||||
return [
|
return [
|
||||||
NAVIGATE,
|
NAVIGATE,
|
||||||
{ keys: "d", label: "Download again" },
|
{ keys: "d", label: "Redownload" },
|
||||||
{ keys: "c", label: "Remove" },
|
{ keys: "c", label: "Remove" },
|
||||||
{ keys: "x", label: "Clear" },
|
{ keys: "x", label: "Clear" },
|
||||||
|
FOLDER,
|
||||||
SWITCH,
|
SWITCH,
|
||||||
ALWAYS,
|
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 [
|
return [
|
||||||
NAVIGATE,
|
NAVIGATE,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface Store {
|
|||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
copyMagnet: (input: { name: string; magnet: string }) => void;
|
copyMagnet: (input: { name: string; magnet: string }) => void;
|
||||||
|
openDownloadFolder: (dir: string) => void;
|
||||||
|
|
||||||
notice: string | null;
|
notice: string | null;
|
||||||
setNotice: (s: string | null) => void;
|
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