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:
yaninni
2026-06-30 02:04:59 +02:00
parent 9cd996178a
commit c114c32d05
5 changed files with 156 additions and 3 deletions
+40
View File
@@ -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")),
);
});
});
+20
View File
@@ -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
View File
@@ -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}
+41
View File
@@ -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>
);
}
+4
View File
@@ -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" },
];