mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: change the download folder from the UI
Add an `o` key that opens an inline prompt to set the download folder, shown in the `?` cheatsheet and the sidebar footer. Reuses Panel + TextField and the captureMode/effective-region mechanism the help overlay already uses, so section keymaps stay quiet while typing a path. No new Store field (editingFolder is local App state, like showHelp). expandHome resolves a leading ~/ and ~\ so a path pasted from another OS still works; the folder is created with fs.mkdir(recursive) and the change fails soft, keeping the old folder if it cannot be created. The setting only feeds new downloads (queue.add reads it at add-time), so in-progress downloads and existing seeds keep their own dir and are never interrupted or relocated.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandHome, normalizeDownloadDir } from "./folder";
|
||||
|
||||
const HOME = path.join(path.sep, "home", "ada");
|
||||
|
||||
describe("expandHome", () => {
|
||||
it("maps a bare tilde to the home directory", () => {
|
||||
expect(expandHome("~", HOME)).toBe(HOME);
|
||||
});
|
||||
|
||||
it("expands a leading ~/ segment", () => {
|
||||
expect(expandHome("~/Movies", HOME)).toBe(path.join(HOME, "Movies"));
|
||||
});
|
||||
|
||||
it("expands a leading ~\\ segment for paths typed on Windows", () => {
|
||||
expect(expandHome("~\\Movies", HOME)).toBe(path.join(HOME, "Movies"));
|
||||
});
|
||||
|
||||
it("leaves an absolute path untouched apart from trimming", () => {
|
||||
const abs = path.join(path.sep, "mnt", "media");
|
||||
expect(expandHome(` ${abs} `, HOME)).toBe(abs);
|
||||
});
|
||||
|
||||
it("does not expand a tilde that is not a path prefix", () => {
|
||||
expect(expandHome("~weird", HOME)).toBe("~weird");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDownloadDir", () => {
|
||||
it("returns an empty string for blank input", () => {
|
||||
expect(normalizeDownloadDir(" ", HOME)).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes a tilde path into a usable directory", () => {
|
||||
expect(normalizeDownloadDir("~/Downloads/torlink", HOME)).toBe(
|
||||
path.normalize(path.join(HOME, "Downloads", "torlink")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// We read the raw input field, so expand a leading ~ ourselves (~\ too, for
|
||||
// paths pasted from Windows). ~bob isn't us, so leave it alone.
|
||||
export function expandHome(input: string, home: string = os.homedir()): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return home;
|
||||
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
||||
return path.join(home, trimmed.slice(2));
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Typed input -> a path for fs.mkdir. Blank returns "" (caller: leave it be).
|
||||
export function normalizeDownloadDir(input: string, home: string = os.homedir()): string {
|
||||
const expanded = expandHome(input, home);
|
||||
if (!expanded) return "";
|
||||
return path.normalize(expanded);
|
||||
}
|
||||
+51
-3
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { loadConfig, saveConfig, type Config } from "../config/config";
|
||||
import { normalizeDownloadDir } from "../config/folder";
|
||||
import { DownloadQueue } from "../download/queue";
|
||||
import { loadQueue, loadSeeds } from "../download/persist";
|
||||
import { loadHistory } from "../download/history";
|
||||
@@ -31,6 +32,7 @@ import { Seeding } from "./components/Seeding";
|
||||
import { Spinner } from "./components/Spinner";
|
||||
import { TabTitle } from "./components/TabTitle";
|
||||
import { Splash } from "./views/Splash";
|
||||
import { FolderPrompt } from "./components/FolderPrompt";
|
||||
import { footerHints } from "./keymap";
|
||||
import { COLOR, ICON } from "./theme";
|
||||
import { useMouseWheel } from "./hooks/useMouseWheel";
|
||||
@@ -80,6 +82,7 @@ export function App({
|
||||
const [downloadFocus, setDownloadFocus] = useState<DownloadFocus | null>(null);
|
||||
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [editingFolder, setEditingFolder] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const booting = useRef(false);
|
||||
|
||||
@@ -150,6 +153,33 @@ export function App({
|
||||
void saveConfig(c);
|
||||
}, []);
|
||||
|
||||
const closeFolderPrompt = useCallback(() => {
|
||||
setEditingFolder(false);
|
||||
setCaptureMode("none");
|
||||
}, []);
|
||||
|
||||
const setDownloadDir = useCallback(
|
||||
(raw: string) => {
|
||||
closeFolderPrompt();
|
||||
const dir = normalizeDownloadDir(raw);
|
||||
if (!config || !dir || dir === config.downloadDir) {
|
||||
if (config && dir && dir === config.downloadDir) setNotice("Download folder unchanged.");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
setNotice(`Couldn't use folder: ${truncate(dir, 48)}`);
|
||||
return;
|
||||
}
|
||||
setConfig({ ...config, downloadDir: dir });
|
||||
setNotice(`Download folder: ${truncate(dir, 48)}`);
|
||||
})();
|
||||
},
|
||||
[config, setConfig, closeFolderPrompt],
|
||||
);
|
||||
|
||||
const startDownload = useCallback(
|
||||
(input: {
|
||||
id: string;
|
||||
@@ -249,7 +279,7 @@ export function App({
|
||||
submitQuery,
|
||||
section,
|
||||
setSection,
|
||||
region: showHelp ? "help" : region,
|
||||
region: showHelp || editingFolder ? "help" : region,
|
||||
setRegion,
|
||||
captureMode,
|
||||
setCaptureMode,
|
||||
@@ -277,6 +307,7 @@ export function App({
|
||||
section,
|
||||
region,
|
||||
showHelp,
|
||||
editingFolder,
|
||||
captureMode,
|
||||
downloadFocus,
|
||||
seedFocus,
|
||||
@@ -307,6 +338,12 @@ export function App({
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
if (input === "o") {
|
||||
setShowHelp(false);
|
||||
setEditingFolder(true);
|
||||
setCaptureMode("text");
|
||||
return;
|
||||
}
|
||||
if (input === "m") {
|
||||
void pasteFromClipboard();
|
||||
return;
|
||||
@@ -373,10 +410,21 @@ export function App({
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{editingFolder ? (
|
||||
<Box marginTop={1}>
|
||||
<FolderPrompt
|
||||
width={Math.max(24, Math.min(cols - 4, 62))}
|
||||
value={store.config.downloadDir}
|
||||
onSubmit={setDownloadDir}
|
||||
onCancel={closeFolderPrompt}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
height={bodyH}
|
||||
marginTop={compact ? 0 : 1}
|
||||
display={showHelp ? "none" : "flex"}
|
||||
display={showHelp || editingFolder ? "none" : "flex"}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Sidebar />
|
||||
@@ -392,7 +440,7 @@ export function App({
|
||||
</Box>
|
||||
|
||||
{showFooter ? (
|
||||
<Box display={showHelp ? "none" : "flex"}>
|
||||
<Box display={showHelp || editingFolder ? "none" : "flex"}>
|
||||
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TextField } from "./TextField";
|
||||
import { Panel } from "./Panel";
|
||||
import { COLOR, ICON } from "../theme";
|
||||
|
||||
interface FolderPromptProps {
|
||||
width: number;
|
||||
value: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function FolderPrompt({ width, value, onSubmit, onCancel }: FolderPromptProps) {
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) onCancel();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Panel title="download folder" width={width} focused height={2}>
|
||||
<Box>
|
||||
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
<TextField
|
||||
defaultValue={value}
|
||||
placeholder="~/Downloads/torlink"
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
<Box marginTop={1}>
|
||||
<Text color={COLOR.alt}>↵</Text>
|
||||
<Text dimColor> save</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
{ keys: "↵", label: "Open" },
|
||||
{ keys: "tab", label: "Switch pane" },
|
||||
{ keys: "esc", label: "Back" },
|
||||
{ keys: "o", label: "Download folder" },
|
||||
{ keys: "q", label: "Quit" },
|
||||
],
|
||||
},
|
||||
@@ -55,6 +56,8 @@ const ALWAYS: Hint = { keys: "?", label: "Keys" };
|
||||
|
||||
const SWITCH: Hint = { keys: "tab", label: "Switch pane" };
|
||||
|
||||
const FOLDER: Hint = { keys: "o", label: "Folder" };
|
||||
|
||||
export function footerHints(
|
||||
region: Region,
|
||||
section: Section,
|
||||
@@ -66,6 +69,7 @@ export function footerHints(
|
||||
NAVIGATE,
|
||||
{ keys: "↵", label: "Open" },
|
||||
SWITCH,
|
||||
FOLDER,
|
||||
ALWAYS,
|
||||
{ keys: "q", label: "Quit" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user