From 5a052fa92094c346e128177a7d60ef6a1e278205 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 11:15:31 +0000 Subject: [PATCH 1/9] fix(rtbit): drop removed torrents from stats_printer phase map (WI-2155) Graft-Changeset: c9c385f871f18e4b28ebcfe35bbf648dfe18628483269c3cb2ba3b92d4bf2d749 Graft-Work-Item: WI-2155 Graft-Batch: 3 --- crates/rtbit/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/rtbit/src/main.rs b/crates/rtbit/src/main.rs index c43af30..1eb7d1f 100644 --- a/crates/rtbit/src/main.rs +++ b/crates/rtbit/src/main.rs @@ -1511,6 +1511,8 @@ async fn stats_printer(session: Arc) -> Result<(), &'static str> { loop { session.with_torrents(|torrents| { let mut last_phase = last_phase.borrow_mut(); + // Rebuilt each tick so removed torrents drop out of the map. + let mut prev_phase = std::mem::take(&mut *last_phase); for (idx, torrent) in torrents { let stats = torrent.stats(); let phase = if matches!(stats.state, TorrentStatsState::Initializing) { @@ -1520,7 +1522,8 @@ async fn stats_printer(session: Arc) -> Result<(), &'static str> { } else { "active" }; - if last_phase.insert(idx, phase) != Some(phase) { + last_phase.insert(idx, phase); + if prev_phase.remove(&idx) != Some(phase) { info!("[{}] {}", idx, phase); } if let TorrentStatsState::Initializing = stats.state { From 3cc4558537bf997732791bb8450c49904cbf085c Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 11:15:30 +0000 Subject: [PATCH 2/9] feat(webui): show Availability, Category, Tracker by default; add Remaining and Save Path columns (#199) Graft-Changeset: ceb6e8b0ba314da7af17c85030eb23c23f01851c663a11ae46e30e99c676c7d53 Graft-Work-Item: WI-2406 Graft-Batch: 3 --- .../src/components/compact/TorrentTable.tsx | 9 ++++++ .../components/compact/TorrentTableRow.tsx | 21 +++++++++++++ .../librtbit/webui/src/stores/columnStore.ts | 30 +++++++++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx index 2799640..c27d573 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx @@ -37,6 +37,8 @@ const TABLE_SORT_COLUMNS = [ "availability", "added_on", "tracker", + "amount_left", + "save_path", ] as const; export type TableSortColumn = (typeof TABLE_SORT_COLUMNS)[number]; @@ -116,6 +118,13 @@ function getTableSortValue( return t.added_on ?? 0; case "tracker": return torrentTrackerHosts(t)[0] ?? ""; + case "amount_left": + return Math.max( + 0, + (t.stats?.total_bytes ?? 0) - (t.stats?.progress_bytes ?? 0), + ); + case "save_path": + return t.output_folder ?? ""; } } diff --git a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx index 64da36e..609d413 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx @@ -452,6 +452,27 @@ const TorrentTableRowUnmemoized: React.FC = ({ ); } + case "amount_left": + return ( + + {formatBytes(Math.max(0, totalBytes - progressBytes))} + + ); + case "save_path": + return ( + +
{torrent.output_folder || "\u2014"}
+ + ); default: return ; } diff --git a/crates/librtbit/webui/src/stores/columnStore.ts b/crates/librtbit/webui/src/stores/columnStore.ts index 81d2165..c505d93 100644 --- a/crates/librtbit/webui/src/stores/columnStore.ts +++ b/crates/librtbit/webui/src/stores/columnStore.ts @@ -22,7 +22,9 @@ export type ColumnId = | "sequential" | "availability" | "added_on" - | "tracker"; + | "tracker" + | "amount_left" + | "save_path"; // IDs of configurable columns in their default order const DEFAULT_CONFIGURABLE_ORDER: ColumnId[] = [ @@ -46,6 +48,8 @@ const DEFAULT_CONFIGURABLE_ORDER: ColumnId[] = [ "availability", "added_on", "tracker", + "amount_left", + "save_path", ]; export interface ColumnDef { @@ -216,7 +220,7 @@ export const COLUMN_DEFS: ColumnDef[] = [ defaultWidth: 120, minWidth: 60, align: "center", - defaultVisible: false, + defaultVisible: true, configurable: true, sortable: true, }, @@ -256,7 +260,7 @@ export const COLUMN_DEFS: ColumnDef[] = [ defaultWidth: 70, minWidth: 50, align: "center", - defaultVisible: false, + defaultVisible: true, configurable: true, sortable: true, }, @@ -276,6 +280,26 @@ export const COLUMN_DEFS: ColumnDef[] = [ defaultWidth: 150, minWidth: 80, align: "center", + defaultVisible: true, + configurable: true, + sortable: true, + }, + { + id: "amount_left", + label: "Remaining", + defaultWidth: 90, + minWidth: 60, + align: "center", + defaultVisible: false, + configurable: true, + sortable: true, + }, + { + id: "save_path", + label: "Save Path", + defaultWidth: 200, + minWidth: 80, + align: "center", defaultVisible: false, configurable: true, sortable: true, From 19a4308782a41ae721345deb06b6f476e47353a2 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 11:16:24 +0000 Subject: [PATCH 3/9] feat(webui): Down/Up Limit columns and current limits in the row menu (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional Down Limit and Up Limit columns to the compact torrent table (hidden by default, sortable; unlimited shows as ∞). The torrent list does not carry per-torrent rate limits, so they are fetched from the per-torrent limits endpoint while a limit column is visible, refreshed every 10s. The row menu now shows the current speed limits and seed limits beside the Set Speed Limits / Set Seed Limits items. Co-Authored-By: Claude Opus 5.5 Graft-Changeset: c5508d3958cf68aeae6319ba5712bfd759344c978e2d0b3e8f5580194f4022ac2 Graft-Work-Item: WI-2405 Graft-Batch: 3 --- .../src/components/TorrentContextMenu.tsx | 55 +++++++++++++++---- .../src/components/compact/TorrentTable.tsx | 27 ++++++++- .../components/compact/TorrentTableRow.tsx | 24 +++++++- .../webui/src/helper/formatRateLimit.ts | 6 ++ .../webui/src/hooks/useTorrentLimits.ts | 48 ++++++++++++++++ .../librtbit/webui/src/stores/columnStore.ts | 24 ++++++++ 6 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 crates/librtbit/webui/src/helper/formatRateLimit.ts create mode 100644 crates/librtbit/webui/src/hooks/useTorrentLimits.ts diff --git a/crates/librtbit/webui/src/components/TorrentContextMenu.tsx b/crates/librtbit/webui/src/components/TorrentContextMenu.tsx index 499751b..38a3666 100644 --- a/crates/librtbit/webui/src/components/TorrentContextMenu.tsx +++ b/crates/librtbit/webui/src/components/TorrentContextMenu.tsx @@ -5,7 +5,9 @@ import { useRef, useState, } from "react"; -import { ErrorDetails, TorrentListItem } from "../api-types"; +import { ErrorDetails, TorrentLimits, TorrentListItem } from "../api-types"; +import { formatRateLimit } from "../helper/formatRateLimit"; +import { formatSecondsToTime } from "../helper/formatSecondsToTime"; import { APIContext } from "../context"; import { useErrorStore } from "../stores/errorStore"; import { useTorrentStore } from "../stores/torrentStore"; @@ -74,6 +76,9 @@ export const TorrentContextMenu: React.FC = ({ const [ulRate, setUlRate] = useState(""); const [ratioLimit, setRatioLimit] = useState(""); const [timeLimit, setTimeLimit] = useState(""); + const [currentLimits, setCurrentLimits] = useState( + null, + ); const newCategoryInputRef = useRef(null); const API = useContext(APIContext); @@ -115,17 +120,28 @@ export const TorrentContextMenu: React.FC = ({ .catch(() => {}); }, [API, setCategories]); - // Fetch current limits when speed/seed limit forms open + // Fetch current speed limits when the menu opens, to show them on the menu item + const singleTargetId = singleTarget?.id; useEffect(() => { - if (showSpeedLimits && singleTarget) { - API.getTorrentLimits(singleTarget.id) - .then((limits) => { - setDlRate(limits.download_rate?.toString() ?? ""); - setUlRate(limits.upload_rate?.toString() ?? ""); - }) - .catch(() => {}); + if (singleTargetId == null) return; + let cancelled = false; + API.getTorrentLimits(singleTargetId) + .then((limits) => { + if (!cancelled) setCurrentLimits(limits); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [singleTargetId, API]); + + // Prefill the speed limit form with the current values when it opens + useEffect(() => { + if (showSpeedLimits && currentLimits) { + setDlRate(currentLimits.download_rate?.toString() ?? ""); + setUlRate(currentLimits.upload_rate?.toString() ?? ""); } - }, [showSpeedLimits, singleTarget, API]); + }, [showSpeedLimits, currentLimits]); useEffect(() => { if (showSeedLimits && singleTarget) { @@ -629,7 +645,13 @@ export const TorrentContextMenu: React.FC = ({ disabled={actionInProgress} > - Set Speed Limits... + Set Speed Limits... + {currentLimits && ( + + ↓ {formatRateLimit(currentLimits.download_rate)} · ↑{" "} + {formatRateLimit(currentLimits.upload_rate)} + + )} {showSpeedLimits && (
@@ -664,7 +686,16 @@ export const TorrentContextMenu: React.FC = ({ disabled={actionInProgress} > - Set Seed Limits... + Set Seed Limits... + + {singleTarget.stats?.seed_ratio_limit != null + ? singleTarget.stats.seed_ratio_limit.toFixed(2) + : "\u221e"}{" "} + ·{" "} + {singleTarget.stats?.seed_time_limit_secs != null + ? formatSecondsToTime(singleTarget.stats.seed_time_limit_secs) + : "\u221e"} + {showSeedLimits && (
diff --git a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx index c27d573..0992b71 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx @@ -1,6 +1,6 @@ import { useMemo, useCallback, useEffect, useState, useRef } from "react"; import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; -import { TorrentListItem } from "../../api-types"; +import { TorrentLimits, TorrentListItem } from "../../api-types"; import { TorrentTableRow } from "./TorrentTableRow"; import { useUIStore } from "../../stores/uiStore"; import { useColumnStore, ColumnDef, ColumnId } from "../../stores/columnStore"; @@ -16,6 +16,7 @@ import { ColumnMenu } from "./ColumnMenu"; import { loadViewPref, oneOf, saveViewPref } from "../../helper/viewPrefs"; import { nextSort } from "../../helper/sortToggle"; import { TorrentContextMenu, ContextMenuState } from "../TorrentContextMenu"; +import { useTorrentLimits } from "../../hooks/useTorrentLimits"; // Sort columns: all sortable column IDs const TABLE_SORT_COLUMNS = [ @@ -26,6 +27,8 @@ const TABLE_SORT_COLUMNS = [ "downloadedBytes", "downSpeed", "upSpeed", + "downLimit", + "upLimit", "uploadedBytes", "eta", "peers", @@ -68,6 +71,7 @@ const RESIZE_STEP_PX = 16; function getTableSortValue( t: TorrentListItem, column: TableSortColumn, + limits: Map, ): number | string { switch (column) { case "id": @@ -86,6 +90,11 @@ function getTableSortValue( return t.stats?.live?.download_speed?.mbps ?? 0; case "upSpeed": return t.stats?.live?.upload_speed?.mbps ?? 0; + case "downLimit": + // Unlimited (unset or 0) sorts above any finite limit + return limits.get(t.id)?.download_rate || Infinity; + case "upLimit": + return limits.get(t.id)?.upload_rate || Infinity; case "uploadedBytes": return t.stats?.live?.snapshot.uploaded_bytes ?? 0; case "eta": { @@ -186,6 +195,16 @@ export const TorrentTable: React.FC = ({ const normalizedQuery = searchQuery.toLowerCase().trim(); + // Per-torrent speed limits, fetched only while a limit column is shown + const showLimits = visibleColumns.some( + (c) => c.id === "downLimit" || c.id === "upLimit", + ); + const torrentIds = useMemo( + () => (torrents ?? []).map((t) => t.id), + [torrents], + ); + const torrentLimits = useTorrentLimits(torrentIds, showLimits); + // Local sorting state const [sortColumn, setSortColumnState] = useState( () => loadViewPref("tableSort", DEFAULT_SORT_PREF, isTableSortPref).column, @@ -251,8 +270,8 @@ export const TorrentTable: React.FC = ({ ), ) .sort((a, b) => { - const aVal = getTableSortValue(a, sortColumn); - const bVal = getTableSortValue(b, sortColumn); + const aVal = getTableSortValue(a, sortColumn, torrentLimits); + const bVal = getTableSortValue(b, sortColumn, torrentLimits); const cmp = typeof aVal === "string" ? aVal.localeCompare(bVal as string) @@ -267,6 +286,7 @@ export const TorrentTable: React.FC = ({ trackerFilter, sortColumn, sortDirection, + torrentLimits, ]); // Compute visible IDs for keyboard navigation @@ -530,6 +550,7 @@ export const TorrentTable: React.FC = ({ onContextMenu={handleRowContextMenu} onCheckboxChange={toggleSelection} visibleColumns={visibleColumns} + limits={torrentLimits.get(torrent.id)} /> ); }, diff --git a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx index 609d413..81e084e 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx @@ -1,6 +1,11 @@ -import { TorrentListItem, STATE_INITIALIZING } from "../../api-types"; +import { + TorrentLimits, + TorrentListItem, + STATE_INITIALIZING, +} from "../../api-types"; import { StatusIcon } from "../StatusIcon"; import { formatBytes } from "../../helper/formatBytes"; +import { formatRateLimit } from "../../helper/formatRateLimit"; import { formatSecondsToTime } from "../../helper/formatSecondsToTime"; import { formatUnixDate } from "../../helper/formatUnixDate"; import { getCompletionETA } from "../../helper/getCompletionETA"; @@ -23,6 +28,8 @@ interface TorrentTableRowProps { onContextMenu: (id: number, e: React.MouseEvent) => void; onCheckboxChange: (id: number) => void; visibleColumns: ColumnDef[]; + /** Per-torrent speed limits; undefined until fetched */ + limits?: TorrentLimits; } /** Human label + badge styling for the torrent state column */ @@ -77,6 +84,7 @@ const TorrentTableRowUnmemoized: React.FC = ({ onContextMenu, onCheckboxChange, visibleColumns, + limits, }) => { const rowRef = useRef(null); @@ -277,6 +285,20 @@ const TorrentTableRowUnmemoized: React.FC = ({ ); } + case "downLimit": + case "upLimit": { + const rate = + col.id === "downLimit" ? limits?.download_rate : limits?.upload_rate; + return ( + + {limits ? formatRateLimit(rate) : "\u2014"} + + ); + } case "uploadedBytes": return ( { + const API = useContext(APIContext); + const [limits, setLimits] = useState>( + () => new Map(), + ); + const key = enabled ? ids.join(",") : ""; + + useEffect(() => { + if (!key) return; + const torrentIds = key.split(",").map(Number); + let cancelled = false; + const load = async () => { + const results = await Promise.all( + torrentIds.map((id) => + API.getTorrentLimits(id) + .then((l): [number, TorrentLimits] => [id, l]) + .catch(() => null), + ), + ); + if (cancelled) return; + setLimits( + new Map(results.filter((r): r is [number, TorrentLimits] => !!r)), + ); + }; + load(); + const timer = setInterval(load, REFRESH_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [API, key]); + + return limits; +} diff --git a/crates/librtbit/webui/src/stores/columnStore.ts b/crates/librtbit/webui/src/stores/columnStore.ts index c505d93..355409c 100644 --- a/crates/librtbit/webui/src/stores/columnStore.ts +++ b/crates/librtbit/webui/src/stores/columnStore.ts @@ -10,6 +10,8 @@ export type ColumnId = | "downloadedBytes" | "downSpeed" | "upSpeed" + | "downLimit" + | "upLimit" | "uploadedBytes" | "eta" | "peers" @@ -35,6 +37,8 @@ const DEFAULT_CONFIGURABLE_ORDER: ColumnId[] = [ "downloadedBytes", "downSpeed", "upSpeed", + "downLimit", + "upLimit", "uploadedBytes", "eta", "peers", @@ -154,6 +158,26 @@ export const COLUMN_DEFS: ColumnDef[] = [ configurable: true, sortable: true, }, + { + id: "downLimit", + label: "Down Limit", + defaultWidth: 90, + minWidth: 60, + align: "center", + defaultVisible: false, + configurable: true, + sortable: true, + }, + { + id: "upLimit", + label: "Up Limit", + defaultWidth: 90, + minWidth: 60, + align: "center", + defaultVisible: false, + configurable: true, + sortable: true, + }, { id: "uploadedBytes", label: "Sent", From ca912e354c0c03f4120e65631866600e84da6574 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 19:50:03 +0000 Subject: [PATCH 4/9] feat(webui): tracker sidebar error and warning groups (#196) Per-tracker counts already existed; add Tracker error (all trackers failing) and Tracker warning (some failing) groups. The torrent list API carries no tracker state, so health is polled every 30s from the per-torrent tracker status endpoint. Graft-Changeset: c129eb5435aa4ee8c9f77b25d34c8dd1361ca83f314a2b2ff625f86194fa1520f Graft-Work-Item: WI-2408 Graft-Batch: 3 --- .../src/components/compact/TrackerFilter.tsx | 89 ++++++++++++++++++- .../webui/src/helper/torrentFilters.ts | 13 ++- .../webui/src/stores/trackerHealthStore.ts | 19 ++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 crates/librtbit/webui/src/stores/trackerHealthStore.ts diff --git a/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx b/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx index 734c916..df78c77 100644 --- a/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx +++ b/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx @@ -1,14 +1,83 @@ -import { useMemo } from "react"; -import { BsBroadcast, BsBroadcastPin } from "react-icons/bs"; +import { useContext, useEffect, useMemo } from "react"; +import { + BsBroadcast, + BsBroadcastPin, + BsExclamationTriangle, + BsXOctagon, +} from "react-icons/bs"; +import { APIContext } from "../../context"; import { useUIStore } from "../../stores/uiStore"; import { useTorrentStore } from "../../stores/torrentStore"; -import { torrentTrackerHosts } from "../../helper/torrentFilters"; +import { + TrackerHealth, + useTrackerHealthStore, +} from "../../stores/trackerHealthStore"; +import { + TRACKER_FILTER_ERROR, + TRACKER_FILTER_WARNING, + torrentTrackerHosts, +} from "../../helper/torrentFilters"; + +/** How often tracker health is re-checked. The list API has no tracker + * state, so this costs one status request per torrent with trackers. */ +const HEALTH_POLL_MS = 30_000; /** Sidebar section: filter torrents by tracker host, qBittorrent-style. */ export const TrackerFilter: React.FC = () => { const torrents = useTorrentStore((state) => state.torrents); const trackerFilter = useUIStore((state) => state.trackerFilter); const setTrackerFilter = useUIStore((state) => state.setTrackerFilter); + const API = useContext(APIContext); + const health = useTrackerHealthStore((state) => state.health); + const setHealth = useTrackerHealthStore((state) => state.setHealth); + + // Ids of torrents with trackers; a stable string so polling doesn't restart + // on every list refresh. + const trackedIds = useMemo( + () => + (torrents ?? []) + .filter((t) => (t.trackers?.length ?? 0) > 0) + .map((t) => t.id) + .join(","), + [torrents], + ); + + useEffect(() => { + const ids = trackedIds ? trackedIds.split(",").map(Number) : []; + let cancelled = false; + const check = async () => { + const next: Record = {}; + await Promise.all( + ids.map(async (id) => { + try { + const { trackers } = await API.getTrackerStatus(id); + const failing = trackers.filter((tr) => tr.state === "error"); + if (failing.length === 0) return; + next[id] = failing.length === trackers.length ? "error" : "warning"; + } catch { + // Torrent removed or status unavailable: treat as healthy. + } + }), + ); + if (!cancelled) setHealth(next); + }; + check(); + const timer = setInterval(check, HEALTH_POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [API, trackedIds, setHealth]); + + const healthCounts = useMemo(() => { + let error = 0; + let warning = 0; + for (const h of Object.values(health)) { + if (h === "error") error++; + else warning++; + } + return { error, warning }; + }, [health]); const trackerCounts = useMemo(() => { const counts: Record = {}; @@ -92,6 +161,20 @@ export const TrackerFilter: React.FC = () => { trackerCounts.trackerless, , )} + {healthCounts.error > 0 && + item( + TRACKER_FILTER_ERROR, + "Tracker error", + healthCounts.error, + , + )} + {healthCounts.warning > 0 && + item( + TRACKER_FILTER_WARNING, + "Tracker warning", + healthCounts.warning, + , + )} {hosts.map((host) => item( host, diff --git a/crates/librtbit/webui/src/helper/torrentFilters.ts b/crates/librtbit/webui/src/helper/torrentFilters.ts index 71b4760..3afddf1 100644 --- a/crates/librtbit/webui/src/helper/torrentFilters.ts +++ b/crates/librtbit/webui/src/helper/torrentFilters.ts @@ -1,6 +1,7 @@ // Shared torrent filtering and sorting utilities import { TorrentListItem } from "../api-types"; +import { useTrackerHealthStore } from "../stores/trackerHealthStore"; // Sort types export type TorrentSortColumn = @@ -117,12 +118,22 @@ export function torrentTrackerHosts(t: TorrentListItem): string[] { return Array.from(hosts); } -// Check if torrent matches tracker filter (a tracker host, "" = trackerless) +// Pseudo tracker filters for the tracker health groups (":" never appears in +// a host returned by trackerHost) +export const TRACKER_FILTER_ERROR = ":error"; +export const TRACKER_FILTER_WARNING = ":warning"; + +// Check if torrent matches tracker filter (a tracker host, "" = trackerless, +// or one of the health groups above) export function matchesTracker( t: TorrentListItem, trackerFilter: string | null, ): boolean { if (trackerFilter === null) return true; + if (trackerFilter === TRACKER_FILTER_ERROR) + return useTrackerHealthStore.getState().health[t.id] === "error"; + if (trackerFilter === TRACKER_FILTER_WARNING) + return useTrackerHealthStore.getState().health[t.id] === "warning"; const hosts = torrentTrackerHosts(t); if (trackerFilter === "") return hosts.length === 0; return hosts.includes(trackerFilter); diff --git a/crates/librtbit/webui/src/stores/trackerHealthStore.ts b/crates/librtbit/webui/src/stores/trackerHealthStore.ts new file mode 100644 index 0000000..0535c06 --- /dev/null +++ b/crates/librtbit/webui/src/stores/trackerHealthStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +/** + * Tracker health per torrent id, derived from the per-torrent tracker status + * endpoint (the torrent list carries announce URLs only): + * "error" = every tracker is failing, "warning" = some trackers are failing. + * Torrents without an entry are healthy or not yet checked. + */ +export type TrackerHealth = "error" | "warning"; + +export interface TrackerHealthState { + health: Record; + setHealth: (health: Record) => void; +} + +export const useTrackerHealthStore = create((set) => ({ + health: {}, + setHealth: (health) => set({ health }), +})); From 92893e7387843cb2a870de011203a4dd30dd010c Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 19:51:05 +0000 Subject: [PATCH 5/9] feat(webui): status filters from 6 to 13 (#195) Add Completed, Running, Stopped, Active, Stalled uploading, Stalled downloading and Checking, derived from torrent state, finished and live speeds. Moving is skipped: the API reports no storage-move state. Sidebar counts reuse matchesStatus; STATUS_FILTERS is shared with uiStore. Graft-Changeset: cbb52e8f7d5df49b3405a3cd09a13ea4bceb2b9da417d83ed51114815588cefd7 Graft-Work-Item: WI-2407 Graft-Batch: 3 --- .../librtbit/webui/src/components/Sidebar.tsx | 103 +++++++----------- .../librtbit/webui/src/helper/statusFilter.ts | 94 ++++++++++++++++ .../webui/src/helper/statusFilters.test.mjs | 39 +++++++ .../webui/src/helper/torrentFilters.ts | 53 ++------- crates/librtbit/webui/src/stores/uiStore.ts | 10 +- 5 files changed, 183 insertions(+), 116 deletions(-) create mode 100644 crates/librtbit/webui/src/helper/statusFilter.ts create mode 100644 crates/librtbit/webui/src/helper/statusFilters.test.mjs diff --git a/crates/librtbit/webui/src/components/Sidebar.tsx b/crates/librtbit/webui/src/components/Sidebar.tsx index 416be5f..97e157a 100644 --- a/crates/librtbit/webui/src/components/Sidebar.tsx +++ b/crates/librtbit/webui/src/components/Sidebar.tsx @@ -4,12 +4,22 @@ import { FaUpload, FaPause, FaExclamationTriangle, + FaCheck, + FaPlay, + FaStop, + FaBolt, + FaSync, } from "react-icons/fa"; import { BsChevronLeft, BsChevronRight, BsCollection } from "react-icons/bs"; import { MdQueue } from "react-icons/md"; import { useUIStore, CurrentPage } from "../stores/uiStore"; import { useTorrentStore } from "../stores/torrentStore"; -import { StatusFilter } from "../helper/torrentFilters"; +import { + StatusFilter, + STATUS_FILTERS, + STATUS_FILTER_LABELS, + matchesStatus, +} from "../helper/torrentFilters"; import { CategoryFilter } from "./compact/CategoryFilter"; import { TrackerFilter } from "./compact/TrackerFilter"; @@ -37,72 +47,39 @@ export const Sidebar: React.FC<{ variant?: "default" | "drawer" }> = ({ }; const statusCounts = useMemo(() => { - if (!torrents) - return { - all: 0, - downloading: 0, - seeding: 0, - paused: 0, - queued: 0, - initializing: 0, - error: 0, - }; - return { - all: torrents.length, - downloading: torrents.filter( - (t) => t.stats?.state === "live" && !t.stats?.finished, - ).length, - seeding: torrents.filter( - (t) => t.stats?.state === "live" && t.stats?.finished, - ).length, - paused: torrents.filter((t) => t.stats?.state === "paused").length, - queued: torrents.filter((t) => t.stats?.queue_state === "Queued").length, - initializing: torrents.filter((t) => t.stats?.state === "initializing") - .length, - error: torrents.filter((t) => t.stats?.state === "error").length, - }; + const counts = {} as Record; + for (const key of STATUS_FILTERS) { + counts[key] = torrents + ? torrents.filter((t) => matchesStatus(t, key)).length + : 0; + } + return counts; }, [torrents]); const iconClass = "w-3.5 h-3.5 shrink-0"; - const filters: FilterItem[] = [ - { - key: "all", - label: "All", - icon: , - count: statusCounts.all, - }, - { - key: "downloading", - label: "Downloading", - icon: , - count: statusCounts.downloading, - }, - { - key: "seeding", - label: "Seeding", - icon: , - count: statusCounts.seeding, - }, - { - key: "paused", - label: "Paused", - icon: , - count: statusCounts.paused, - }, - { - key: "queued", - label: "Queued", - icon: , - count: statusCounts.queued, - }, - { - key: "error", - label: "Error", - icon: , - count: statusCounts.error, - }, - ]; + const icons: Record = { + all: , + downloading: , + seeding: , + completed: , + running: , + stopped: , + active: , + stalled_up: , + stalled_down: , + checking: , + paused: , + queued: , + error: , + }; + + const filters: FilterItem[] = STATUS_FILTERS.map((key) => ({ + key, + label: STATUS_FILTER_LABELS[key], + icon: icons[key], + count: statusCounts[key], + })); const activeItemClass = "bg-primary/10 text-primary font-medium"; const inactiveItemClass = diff --git a/crates/librtbit/webui/src/helper/statusFilter.ts b/crates/librtbit/webui/src/helper/statusFilter.ts new file mode 100644 index 0000000..415a9e4 --- /dev/null +++ b/crates/librtbit/webui/src/helper/statusFilter.ts @@ -0,0 +1,94 @@ +// Torrent status filters: kept free of store imports so node tests can load it + +import type { TorrentListItem } from "../api-types"; + +// Status filter types +export type StatusFilter = + | "all" + | "downloading" + | "seeding" + | "completed" + | "running" + | "stopped" + | "active" + | "stalled_up" + | "stalled_down" + | "checking" + | "paused" + | "queued" + | "error"; + +// Every status filter, in sidebar order. No "moving" filter: the API does not +// report storage moves in torrent state. +export const STATUS_FILTERS: readonly StatusFilter[] = [ + "all", + "downloading", + "seeding", + "completed", + "running", + "stopped", + "active", + "stalled_up", + "stalled_down", + "checking", + "paused", + "queued", + "error", +]; + +// Status filter display labels +export const STATUS_FILTER_LABELS: Record = { + all: "All", + downloading: "Downloading", + seeding: "Seeding", + completed: "Completed", + running: "Running", + stopped: "Stopped", + active: "Active", + stalled_up: "Stalled uploading", + stalled_down: "Stalled downloading", + checking: "Checking", + paused: "Paused", + queued: "Queued", + error: "Error", +}; + +// Check if torrent matches status filter +export function matchesStatus( + t: TorrentListItem, + filter: StatusFilter, +): boolean { + if (filter === "all") return true; + + const state = t.stats?.state; + const finished = t.stats?.finished; + const downMbps = t.stats?.live?.download_speed?.mbps ?? 0; + const upMbps = t.stats?.live?.upload_speed?.mbps ?? 0; + + switch (filter) { + case "downloading": + return state === "live" && !finished; + case "seeding": + return state === "live" && !!finished; + case "completed": + return !!finished; + case "running": + return state === "live"; + case "stopped": + return state === "paused" || state === "error"; + case "active": + return state === "live" && (downMbps > 0 || upMbps > 0); + case "stalled_up": + return state === "live" && !!finished && upMbps <= 0; + case "stalled_down": + return state === "live" && !finished && downMbps <= 0; + case "checking": + return state === "initializing"; + case "paused": + return state === "paused"; + case "queued": + return t.stats?.queue_state === "Queued"; + case "error": + return state === "error"; + } +} diff --git a/crates/librtbit/webui/src/helper/statusFilters.test.mjs b/crates/librtbit/webui/src/helper/statusFilters.test.mjs new file mode 100644 index 0000000..5f40c8b --- /dev/null +++ b/crates/librtbit/webui/src/helper/statusFilters.test.mjs @@ -0,0 +1,39 @@ +// @ts-nocheck +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + matchesStatus, + STATUS_FILTERS, + STATUS_FILTER_LABELS, +} from "./statusFilter.ts"; + +const t = (state, finished, down = 0, up = 0) => ({ + id: 1, + name: "x", + stats: { + state, + finished, + live: + state === "live" + ? { download_speed: { mbps: down }, upload_speed: { mbps: up } } + : null, + }, +}); + +test("thirteen filters, each labelled", () => { + assert.equal(STATUS_FILTERS.length, 13); + for (const f of STATUS_FILTERS) assert.ok(STATUS_FILTER_LABELS[f]); +}); + +test("derived filters", () => { + assert.ok(matchesStatus(t("live", false, 1), "active")); + assert.ok(!matchesStatus(t("live", false), "active")); + assert.ok(matchesStatus(t("live", false), "stalled_down")); + assert.ok(matchesStatus(t("live", true), "stalled_up")); + assert.ok(!matchesStatus(t("live", true, 0, 2), "stalled_up")); + assert.ok(matchesStatus(t("paused", true), "completed")); + assert.ok(matchesStatus(t("paused", false), "stopped")); + assert.ok(matchesStatus(t("error", false), "stopped")); + assert.ok(matchesStatus(t("live", false), "running")); + assert.ok(matchesStatus(t("initializing", false), "checking")); +}); diff --git a/crates/librtbit/webui/src/helper/torrentFilters.ts b/crates/librtbit/webui/src/helper/torrentFilters.ts index 3afddf1..738c704 100644 --- a/crates/librtbit/webui/src/helper/torrentFilters.ts +++ b/crates/librtbit/webui/src/helper/torrentFilters.ts @@ -1,7 +1,15 @@ // Shared torrent filtering and sorting utilities -import { TorrentListItem } from "../api-types"; +import type { TorrentListItem } from "../api-types"; import { useTrackerHealthStore } from "../stores/trackerHealthStore"; +import { StatusFilter, matchesStatus } from "./statusFilter"; + +export type { StatusFilter } from "./statusFilter"; +export { + STATUS_FILTERS, + STATUS_FILTER_LABELS, + matchesStatus, +} from "./statusFilter"; // Sort types export type TorrentSortColumn = @@ -16,15 +24,6 @@ export type TorrentSortColumn = export type SortDirection = "asc" | "desc"; -// Status filter types -export type StatusFilter = - | "all" - | "downloading" - | "seeding" - | "paused" - | "queued" - | "error"; - // Sort column display labels export const SORT_COLUMN_LABELS: Record = { id: "ID", @@ -37,16 +36,6 @@ export const SORT_COLUMN_LABELS: Record = { category: "Category", }; -// Status filter display labels -export const STATUS_FILTER_LABELS: Record = { - all: "All", - downloading: "Downloading", - seeding: "Seeding", - paused: "Paused", - queued: "Queued", - error: "Error", -}; - // Get sort value for a torrent export function getSortValue( t: TorrentListItem, @@ -149,30 +138,6 @@ export function matchesCategory( return torrent.category === categoryFilter; } -// Check if torrent matches status filter -export function matchesStatus( - t: TorrentListItem, - filter: StatusFilter, -): boolean { - if (filter === "all") return true; - - const state = t.stats?.state; - const finished = t.stats?.finished; - - switch (filter) { - case "downloading": - return state === "live" && !finished; - case "seeding": - return state === "live" && !!finished; - case "paused": - return state === "paused"; - case "queued": - return t.stats?.queue_state === "Queued"; - case "error": - return state === "error"; - } -} - // Combined visibility check export function isTorrentVisible( t: TorrentListItem, diff --git a/crates/librtbit/webui/src/stores/uiStore.ts b/crates/librtbit/webui/src/stores/uiStore.ts index 43ec599..f15830c 100644 --- a/crates/librtbit/webui/src/stores/uiStore.ts +++ b/crates/librtbit/webui/src/stores/uiStore.ts @@ -3,6 +3,7 @@ import { TorrentSortColumn, SortDirection, StatusFilter, + STATUS_FILTERS, } from "../helper/torrentFilters"; import { CategoryInfo } from "../api-types"; import { @@ -13,15 +14,6 @@ import { isStringOrNull, } from "../helper/viewPrefs"; -const STATUS_FILTERS: readonly StatusFilter[] = [ - "all", - "downloading", - "seeding", - "paused", - "queued", - "error", -]; - const LARGE_SCREEN_BREAKPOINT = 1024; function getDefaultViewMode(): "full" | "compact" { From 60d70f6c08364e24278a40b3dc017299bef078bf Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 19:51:08 +0000 Subject: [PATCH 6/9] =?UTF-8?q?feat(webui):=20Settings=20Behaviour=20tab?= =?UTF-8?q?=20=E2=80=94=20queue=20limits,=20seeding=20defaults,=20start=20?= =?UTF-8?q?paused,=20double-click=20action=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graft-Changeset: c351a785cc837af091124da0a533e329010e456809c38632281b46338eaed6352 Graft-Work-Item: WI-2409 Graft-Batch: 3 --- .../src/components/compact/TorrentTable.tsx | 51 +++++++++++- .../components/compact/TorrentTableRow.tsx | 7 ++ .../src/components/config/BehaviourTab.tsx | 78 +++++++++++++++++++ .../src/components/config/ConfigModal.tsx | 14 +--- .../src/components/config/settingsRegistry.ts | 27 ++++--- .../components/modal/FileSelectionModal.tsx | 11 +++ .../modal/MultiTorrentUploadModal.tsx | 3 +- .../webui/src/helper/behaviourPrefs.ts | 23 ++++++ .../src/helper/settingsRegistry.test.mjs | 11 +++ 9 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 crates/librtbit/webui/src/components/config/BehaviourTab.tsx create mode 100644 crates/librtbit/webui/src/helper/behaviourPrefs.ts diff --git a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx index 0992b71..752f554 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx @@ -1,4 +1,11 @@ -import { useMemo, useCallback, useEffect, useState, useRef } from "react"; +import { + useContext, + useMemo, + useCallback, + useEffect, + useState, + useRef, +} from "react"; import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; import { TorrentLimits, TorrentListItem } from "../../api-types"; import { TorrentTableRow } from "./TorrentTableRow"; @@ -17,6 +24,10 @@ import { loadViewPref, oneOf, saveViewPref } from "../../helper/viewPrefs"; import { nextSort } from "../../helper/sortToggle"; import { TorrentContextMenu, ContextMenuState } from "../TorrentContextMenu"; import { useTorrentLimits } from "../../hooks/useTorrentLimits"; +import { loadDoubleClickAction } from "../../helper/behaviourPrefs"; +import { APIContext } from "../../context"; +import { useTorrentStore } from "../../stores/torrentStore"; +import { useErrorStore } from "../../stores/errorStore"; // Sort columns: all sortable column IDs const TABLE_SORT_COLUMNS = [ @@ -361,6 +372,42 @@ export const TorrentTable: React.FC = ({ [selectRange, selectTorrent], ); + // Row double-click: the action is chosen in Settings > Behaviour + const API = useContext(APIContext); + const refreshTorrents = useTorrentStore((s) => s.refreshTorrents); + const setCloseableError = useErrorStore((s) => s.setCloseableError); + const openDetailsModal = useUIStore((s) => s.openDetailsModal); + const handleRowDoubleClick = useCallback( + (id: number) => { + const action = loadDoubleClickAction(); + if (action === "details") { + openDetailsModal(id); + return; + } + if (action !== "toggle") return; + const state = filteredTorrents?.find((t) => t.id === id)?.stats?.state; + const request = + state === "live" + ? API.pause(id) + : state === "paused" || state === "error" + ? API.start(id) + : null; + request?.then(refreshTorrents, (e) => + setCloseableError({ + text: `Error ${state === "live" ? "pausing" : "starting"} torrent id=${id}`, + details: e, + }), + ); + }, + [ + API, + filteredTorrents, + openDetailsModal, + refreshTorrents, + setCloseableError, + ], + ); + // Opens the torrent menu for a row; shared by right-click and the keyboard (Shift+F10 / ContextMenu) const openTorrentMenu = useCallback( (id: number, pos: { x: number; y: number; anchor?: DOMRect }) => { @@ -547,6 +594,7 @@ export const TorrentTable: React.FC = ({ consumeRowFocus={consumeRowFocus} onRowFocus={handleRowFocus} onRowClick={handleRowClick} + onRowDoubleClick={handleRowDoubleClick} onContextMenu={handleRowContextMenu} onCheckboxChange={toggleSelection} visibleColumns={visibleColumns} @@ -561,6 +609,7 @@ export const TorrentTable: React.FC = ({ consumeRowFocus, handleRowFocus, handleRowClick, + handleRowDoubleClick, handleRowContextMenu, toggleSelection, visibleColumns, diff --git a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx index 81e084e..6a9be76 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTableRow.tsx @@ -25,6 +25,7 @@ interface TorrentTableRowProps { consumeRowFocus: () => boolean; onRowFocus: (index: number) => void; onRowClick: (id: number, e: React.MouseEvent) => void; + onRowDoubleClick: (id: number) => void; onContextMenu: (id: number, e: React.MouseEvent) => void; onCheckboxChange: (id: number) => void; visibleColumns: ColumnDef[]; @@ -81,6 +82,7 @@ const TorrentTableRowUnmemoized: React.FC = ({ consumeRowFocus, onRowFocus, onRowClick, + onRowDoubleClick, onContextMenu, onCheckboxChange, visibleColumns, @@ -516,6 +518,11 @@ const TorrentTableRowUnmemoized: React.FC = ({ if (e.target === e.currentTarget) onRowFocus(rowIndex); }} onMouseDown={handleRowClick} + onDoubleClick={(e) => { + // The selection checkbox has its own click handling + if ((e.target as HTMLElement).closest("input, button")) return; + onRowDoubleClick(torrent.id); + }} onContextMenu={handleContextMenu} className={`focus-ring cursor-pointer border-b border-divider/60 text-sm h-8 pointer-coarse:h-11 transition-colors ${ isSelected diff --git a/crates/librtbit/webui/src/components/config/BehaviourTab.tsx b/crates/librtbit/webui/src/components/config/BehaviourTab.tsx new file mode 100644 index 0000000..722f8ca --- /dev/null +++ b/crates/librtbit/webui/src/components/config/BehaviourTab.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +import { Fieldset } from "../forms/Fieldset"; +import { FormCheckbox } from "../forms/FormCheckbox"; +import { QueueTab, QueueTabProps } from "./QueueTab"; +import { SeedingTab } from "./SeedingTab"; +import { settingLabel } from "./settingsRegistry"; +import { + DoubleClickAction, + loadDoubleClickAction, + loadStartPaused, + saveDoubleClickAction, + saveStartPaused, +} from "../../helper/behaviourPrefs"; + +const DOUBLE_CLICK_LABELS: Record = { + details: "Open torrent details", + toggle: "Start or pause the torrent", + none: "Do nothing", +}; + +export const BehaviourTab: React.FC = (queueProps) => { + const [startPaused, setStartPaused] = useState(loadStartPaused); + const [doubleClick, setDoubleClick] = useState(loadDoubleClickAction); + + return ( +
+ + + +
+ { + setStartPaused(!startPaused); + saveStartPaused(!startPaused); + }} + /> +
+ +
+
+ + + + What double-clicking a row in the torrent list does. Saved in this + browser. + +
+
+
+ ); +}; diff --git a/crates/librtbit/webui/src/components/config/ConfigModal.tsx b/crates/librtbit/webui/src/components/config/ConfigModal.tsx index e377c5d..84b726a 100644 --- a/crates/librtbit/webui/src/components/config/ConfigModal.tsx +++ b/crates/librtbit/webui/src/components/config/ConfigModal.tsx @@ -6,8 +6,7 @@ import { ConnectionTab } from "./ConnectionTab"; import { DHTTab } from "./DHTTab"; import { HttpApiTab } from "./HttpApiTab"; import { SecurityTab } from "./SecurityTab"; -import { SeedingTab } from "./SeedingTab"; -import { QueueTab } from "./QueueTab"; +import { BehaviourTab } from "./BehaviourTab"; import { RSSTab } from "./RSSTab"; import { FoldersTab } from "./FoldersTab"; import { SETTINGS } from "./settingsRegistry"; @@ -149,10 +148,10 @@ export const ConfigModal: React.FC = ({ content: , }, { - id: "queue", - label: "Queue", + id: "behaviour", + label: "Behaviour", content: ( - = ({ /> ), }, - { - id: "seeding", - label: "Seeding", - content: , - }, { id: "dht", label: "DHT", diff --git a/crates/librtbit/webui/src/components/config/settingsRegistry.ts b/crates/librtbit/webui/src/components/config/settingsRegistry.ts index 0a1c4aa..33506e7 100644 --- a/crates/librtbit/webui/src/components/config/settingsRegistry.ts +++ b/crates/librtbit/webui/src/components/config/settingsRegistry.ts @@ -7,8 +7,7 @@ export type SettingsTabId = | "folders" | "speed" | "connections" - | "queue" - | "seeding" + | "behaviour" | "dht" | "security" | "http-api" @@ -78,22 +77,32 @@ export const SETTINGS: readonly SettingDefinition[] = [ description: "Set with --listen-ip.", }, - { id: "max-active-downloads", tab: "queue", label: "Max active downloads" }, - { id: "max-active-uploads", tab: "queue", label: "Max active uploads" }, - { id: "max-active-total", tab: "queue", label: "Max active total" }, + { id: "max-active-downloads", tab: "behaviour", label: "Max active downloads" }, + { id: "max-active-uploads", tab: "behaviour", label: "Max active uploads" }, + { id: "max-active-total", tab: "behaviour", label: "Max active total" }, { id: "seed-ratio-enabled", - tab: "seeding", + tab: "behaviour", label: "Limit seed ratio globally", }, - { id: "seed-ratio-limit", tab: "seeding", label: "Ratio limit" }, + { id: "seed-ratio-limit", tab: "behaviour", label: "Ratio limit" }, { id: "seed-time-enabled", - tab: "seeding", + tab: "behaviour", label: "Limit seeding time globally", }, - { id: "seed-time-limit", tab: "seeding", label: "Time limit (hours)" }, + { id: "seed-time-limit", tab: "behaviour", label: "Time limit (hours)" }, + { + id: "add-start-paused", + tab: "behaviour", + label: "Start torrents paused", + }, + { + id: "double-click-action", + tab: "behaviour", + label: "Double-click action", + }, { id: "dht-enabled", diff --git a/crates/librtbit/webui/src/components/modal/FileSelectionModal.tsx b/crates/librtbit/webui/src/components/modal/FileSelectionModal.tsx index 4c91a02..6ee49d0 100644 --- a/crates/librtbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librtbit/webui/src/components/modal/FileSelectionModal.tsx @@ -11,6 +11,8 @@ import { Button } from "../buttons/Button"; import { Fieldset } from "../forms/Fieldset"; import { FormInput } from "../forms/FormInput"; import { Form } from "../forms/Form"; +import { FormCheckbox } from "../forms/FormCheckbox"; +import { loadStartPaused } from "../../helper/behaviourPrefs"; import { FileListInput } from "../FileListInput"; import { useTorrentStore } from "../../stores/torrentStore"; @@ -33,6 +35,7 @@ export const FileSelectionModal = (props: { const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [unpopularTorrent, setUnpopularTorrent] = useState(false); + const [startTorrent, setStartTorrent] = useState(() => !loadStartPaused()); const [outputFolder, setOutputFolder] = useState(""); const refreshTorrents = useTorrentStore((state) => state.refreshTorrents); const API = useContext(APIContext); @@ -85,6 +88,7 @@ export const FileSelectionModal = (props: { only_files: allSelected ? undefined : Array.from(selectedFiles), initial_peers: initialPeers, output_folder: outputFolder, + paused: !startTorrent, }; if (unpopularTorrent) { opts.peer_opts = { @@ -131,6 +135,13 @@ export const FileSelectionModal = (props: { /> + setStartTorrent(!startTorrent)} + name="start_torrent" + /> + {/*
!loadStartPaused()); const [entries, setEntries] = useState( files.map((file) => ({ file, status: "pending" })), ); diff --git a/crates/librtbit/webui/src/helper/behaviourPrefs.ts b/crates/librtbit/webui/src/helper/behaviourPrefs.ts new file mode 100644 index 0000000..e2b3244 --- /dev/null +++ b/crates/librtbit/webui/src/helper/behaviourPrefs.ts @@ -0,0 +1,23 @@ +// Per-browser behaviour preferences from the Settings > Behaviour tab. They +// only change what this web UI does, so they live in localStorage beside the +// view preferences rather than in the engine. + +import { isBoolean, loadViewPref, oneOf, saveViewPref } from "./viewPrefs"; + +export const DOUBLE_CLICK_ACTIONS = ["details", "toggle", "none"] as const; +export type DoubleClickAction = (typeof DOUBLE_CLICK_ACTIONS)[number]; + +const START_PAUSED_KEY = "behaviour.startPaused"; +const DOUBLE_CLICK_KEY = "behaviour.doubleClick"; + +export const loadStartPaused = (): boolean => + loadViewPref(START_PAUSED_KEY, false, isBoolean); + +export const saveStartPaused = (value: boolean): void => + saveViewPref(START_PAUSED_KEY, value); + +export const loadDoubleClickAction = (): DoubleClickAction => + loadViewPref(DOUBLE_CLICK_KEY, "details", oneOf(DOUBLE_CLICK_ACTIONS)); + +export const saveDoubleClickAction = (value: DoubleClickAction): void => + saveViewPref(DOUBLE_CLICK_KEY, value); diff --git a/crates/librtbit/webui/src/helper/settingsRegistry.test.mjs b/crates/librtbit/webui/src/helper/settingsRegistry.test.mjs index 865d0c9..5da83cd 100644 --- a/crates/librtbit/webui/src/helper/settingsRegistry.test.mjs +++ b/crates/librtbit/webui/src/helper/settingsRegistry.test.mjs @@ -35,3 +35,14 @@ test("restartRequiredSettings filters by tab", () => { ); assert.deepEqual([...tabs], ["connections"]); }); + +test("queue, seeding and add settings share the Behaviour tab", () => { + for (const id of [ + "max-active-downloads", + "seed-ratio-limit", + "add-start-paused", + "double-click-action", + ]) { + assert.equal(SETTINGS.find((s) => s.id === id)?.tab, "behaviour", id); + } +}); From d66bef91229ac30f50a57dbc5f4b58008e0775e4 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 19:54:51 +0000 Subject: [PATCH 7/9] feat(webui): Settings Downloads tab with incomplete-file suffix (#203) Adds GET/POST /session/downloads exposing the existing .part incomplete-file support (set_use_part_files had no caller), persisted in session settings, and a Downloads tab to toggle it. The add-torrent paused default is the Behaviour tab's "Start torrents paused" (#202), so it is not duplicated here. Temp path and full pre-allocation are not included: move-on-completion covers the temp-folder flow and storage only creates sparse files. Graft-Changeset: c2ee920648e82a59ca94ba126c50bbebf1ec98ecf8a857a97617829aa9ad5d169 Graft-Work-Item: WI-2410 Graft-Batch: 3 --- .../src/http_api/handlers/configure.rs | 29 ++++++++ crates/librtbit/src/http_api/handlers/mod.rs | 2 + crates/librtbit/src/session/mod.rs | 11 ++- .../librtbit/src/session_persistence/json.rs | 1 + .../librtbit/src/session_persistence/mod.rs | 3 + crates/librtbit/webui/src/api-types.ts | 6 ++ .../src/components/config/ConfigModal.tsx | 6 ++ .../src/components/config/DownloadsTab.tsx | 70 +++++++++++++++++++ .../src/components/config/settingsRegistry.ts | 7 ++ crates/librtbit/webui/src/context.tsx | 7 ++ crates/librtbit/webui/src/http-api.ts | 7 ++ crates/librtbit/webui/src/mock-api.ts | 4 ++ 12 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 crates/librtbit/webui/src/components/config/DownloadsTab.tsx diff --git a/crates/librtbit/src/http_api/handlers/configure.rs b/crates/librtbit/src/http_api/handlers/configure.rs index 8a1cf49..09a950f 100644 --- a/crates/librtbit/src/http_api/handlers/configure.rs +++ b/crates/librtbit/src/http_api/handlers/configure.rs @@ -78,6 +78,35 @@ pub async fn h_set_folders( Ok(Json(EmptyJsonResponse {})) } +// --- Downloads settings --- + +/// Session-wide download behaviour shown on the settings Downloads tab. +#[derive(Serialize, Deserialize)] +pub struct DownloadsConfig { + /// Write incomplete files under a `.part` name; applies to torrents added afterwards. + pub incomplete_file_suffix: bool, +} + +pub async fn h_get_downloads(State(state): State) -> Result { + let session = state.api.session(); + Ok(Json(DownloadsConfig { + incomplete_file_suffix: session.use_part_files(), + })) +} + +pub async fn h_set_downloads( + State(state): State, + Json(config): Json, +) -> Result { + let session = state.api.session(); + session.set_use_part_files(config.incomplete_file_suffix); + session + .persist_session_settings() + .await + .map_err(ApiError::from)?; + Ok(Json(EmptyJsonResponse {})) +} + // --- Directory browsing --- #[derive(Deserialize)] diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 8a94ee4..f730f17 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -130,6 +130,7 @@ pub fn make_api_router(state: ApiState) -> Router { .route("/speed/schedule", get(speed::h_get_speed_schedule)) .route("/torrents/categories", get(torrents::h_list_categories)) .route("/session/folders", get(configure::h_get_folders)) + .route("/session/downloads", get(configure::h_get_downloads)) .route("/fs/browse", get(configure::h_browse_directory)); #[cfg(feature = "tracing-subscriber-utils")] @@ -203,6 +204,7 @@ pub fn make_api_router(state: ApiState) -> Router { post(torrents::h_set_torrent_rate_limits), ) .route("/session/folders", post(configure::h_set_folders)) + .route("/session/downloads", post(configure::h_set_downloads)) .route("/speed/alt", post(speed::h_toggle_alt_speed)) .route("/speed/alt/config", post(speed::h_set_alt_speed_config)) .route("/speed/schedule", post(speed::h_set_speed_schedule)); diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index 4612e28..f8ed426 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -442,6 +442,7 @@ impl Session { let mut resolved_completed_folder = opts.completed_folder.clone(); let mut max_active_downloads = 0; let mut max_active_seeds = 0; + let mut use_part_files = false; if let Some(p) = persistence.as_ref() { match p.load_session_settings().await { Ok(settings) => { @@ -453,6 +454,7 @@ impl Session { } max_active_downloads = encode_limit(settings.max_active_downloads); max_active_seeds = encode_limit(settings.max_active_seeds); + use_part_files = settings.part_files.unwrap_or(false); } Err(e) => warn!("error loading session settings from persistence: {e:#}"), } @@ -529,7 +531,7 @@ impl Session { max_active_downloads: AtomicUsize::new(max_active_downloads), max_active_seeds: AtomicUsize::new(max_active_seeds), min_free_disk_space: std::sync::atomic::AtomicU64::new(0), - use_part_files: std::sync::atomic::AtomicBool::new(false), + use_part_files: std::sync::atomic::AtomicBool::new(use_part_files), alt_speed: Default::default(), }); @@ -1477,6 +1479,12 @@ impl Session { .store(enabled, std::sync::atomic::Ordering::Relaxed); } + /// Whether new torrents write incomplete files under a `.part` name. + pub fn use_part_files(&self) -> bool { + self.use_part_files + .load(std::sync::atomic::Ordering::Relaxed) + } + /// Set the completed folder (move-on-complete destination). pub fn set_completed_folder(&self, folder: Option) { *self.completed_folder.write() = folder; @@ -1492,6 +1500,7 @@ impl Session { completed_folder: self.completed_folder(), max_active_downloads: self.max_active_downloads(), max_active_seeds: self.max_active_seeds(), + part_files: Some(self.use_part_files()), }) .await } diff --git a/crates/librtbit/src/session_persistence/json.rs b/crates/librtbit/src/session_persistence/json.rs index 7b4add4..6295985 100644 --- a/crates/librtbit/src/session_persistence/json.rs +++ b/crates/librtbit/src/session_persistence/json.rs @@ -484,6 +484,7 @@ mod tests { completed_folder: Some(dir.path().join("completed/{category}")), max_active_downloads: Some(3), max_active_seeds: None, + part_files: Some(true), }; store.store_session_settings(&settings).await.unwrap(); diff --git a/crates/librtbit/src/session_persistence/mod.rs b/crates/librtbit/src/session_persistence/mod.rs index 54330af..7738f3f 100644 --- a/crates/librtbit/src/session_persistence/mod.rs +++ b/crates/librtbit/src/session_persistence/mod.rs @@ -36,6 +36,9 @@ pub struct SessionSettings { pub max_active_downloads: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_active_seeds: Option, + /// Write new torrents' incomplete files under `.part` names. Absent means off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub part_files: Option, } #[derive(Serialize, Deserialize, Clone)] diff --git a/crates/librtbit/webui/src/api-types.ts b/crates/librtbit/webui/src/api-types.ts index 3784062..0fdfd57 100644 --- a/crates/librtbit/webui/src/api-types.ts +++ b/crates/librtbit/webui/src/api-types.ts @@ -467,6 +467,10 @@ export interface RssSettings { } // Folder configuration +export interface DownloadsConfig { + incomplete_file_suffix: boolean; +} + export interface FoldersConfig { download_folder: string; completed_folder: string | null; @@ -554,5 +558,7 @@ export interface RtbitAPI { // Folder management getFolders: () => Promise; setFolders: (config: FoldersConfig) => Promise; + getDownloadsConfig: () => Promise; + setDownloadsConfig: (config: DownloadsConfig) => Promise; browseDirectory: (path?: string) => Promise; } diff --git a/crates/librtbit/webui/src/components/config/ConfigModal.tsx b/crates/librtbit/webui/src/components/config/ConfigModal.tsx index 84b726a..763b8e6 100644 --- a/crates/librtbit/webui/src/components/config/ConfigModal.tsx +++ b/crates/librtbit/webui/src/components/config/ConfigModal.tsx @@ -9,6 +9,7 @@ import { SecurityTab } from "./SecurityTab"; import { BehaviourTab } from "./BehaviourTab"; import { RSSTab } from "./RSSTab"; import { FoldersTab } from "./FoldersTab"; +import { DownloadsTab } from "./DownloadsTab"; import { SETTINGS } from "./settingsRegistry"; import { APIContext } from "../../context"; import { LimitsConfig, ErrorDetails } from "../../api-types"; @@ -118,6 +119,11 @@ export const ConfigModal: React.FC = ({ /> ), }, + { + id: "downloads", + label: "Downloads", + content: , + }, { id: "speed", label: "Speed", diff --git a/crates/librtbit/webui/src/components/config/DownloadsTab.tsx b/crates/librtbit/webui/src/components/config/DownloadsTab.tsx new file mode 100644 index 0000000..890f8b9 --- /dev/null +++ b/crates/librtbit/webui/src/components/config/DownloadsTab.tsx @@ -0,0 +1,70 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Fieldset } from "../forms/Fieldset"; +import { FormCheckbox } from "../forms/FormCheckbox"; +import { APIContext } from "../../context"; +import { DownloadsConfig } from "../../api-types"; +import { Spinner } from "../Spinner"; + +// Saved as soon as a box is toggled, like the Seeding tab. +export const DownloadsTab: React.FC = () => { + const API = useContext(APIContext); + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + API.getDownloadsConfig() + .then(setConfig) + .catch(() => setError("Could not load download settings")); + }, [API]); + + const save = async (next: DownloadsConfig) => { + const previous = config; + setConfig(next); + setError(null); + try { + await API.setDownloadsConfig(next); + } catch { + setConfig(previous); + setError("Could not save download settings"); + } + }; + + if (!config) { + return error ? ( +
{error}
+ ) : ( +
+ +
+ ); + } + + return ( +
+
+ + save({ + ...config, + incomplete_file_suffix: !config.incomplete_file_suffix, + }) + } + /> +
+ {error && ( +
+ {error} +
+ )} +
+ Download and move-on-completion folders are on the Folders tab; the + add-torrent "Start torrents paused" default is on the Behaviour tab. + Files are created sparse; full pre-allocation is not supported. +
+
+ ); +}; diff --git a/crates/librtbit/webui/src/components/config/settingsRegistry.ts b/crates/librtbit/webui/src/components/config/settingsRegistry.ts index 33506e7..7e6b20b 100644 --- a/crates/librtbit/webui/src/components/config/settingsRegistry.ts +++ b/crates/librtbit/webui/src/components/config/settingsRegistry.ts @@ -5,6 +5,7 @@ export type SettingsTabId = | "general" | "folders" + | "downloads" | "speed" | "connections" | "behaviour" @@ -41,6 +42,12 @@ export const SETTINGS: readonly SettingDefinition[] = [ "Where session state is persisted. Set with --persistence-location.", }, + { + id: "incomplete-file-suffix", + tab: "downloads", + label: "Append .part to incomplete files", + }, + { id: "download-rate-limit", tab: "speed", label: "Download rate limit" }, { id: "upload-rate-limit", tab: "speed", label: "Upload rate limit" }, { id: "peer-limit", tab: "speed", label: "Peer limit" }, diff --git a/crates/librtbit/webui/src/context.tsx b/crates/librtbit/webui/src/context.tsx index 651e167..cbbb3e6 100644 --- a/crates/librtbit/webui/src/context.tsx +++ b/crates/librtbit/webui/src/context.tsx @@ -7,6 +7,7 @@ import { CategoryInfo, DhtStats, FoldersConfig, + DownloadsConfig, LimitsConfig, RtbitAPI, SeedLimits, @@ -160,6 +161,12 @@ export const APIContext = createContext({ setFolders: function (_config: FoldersConfig): Promise { throw new Error("Function not implemented."); }, + getDownloadsConfig: function (): Promise { + throw new Error("Function not implemented."); + }, + setDownloadsConfig: function (_config: DownloadsConfig): Promise { + throw new Error("Function not implemented."); + }, browseDirectory: function (_path?: string): Promise { throw new Error("Function not implemented."); }, diff --git a/crates/librtbit/webui/src/http-api.ts b/crates/librtbit/webui/src/http-api.ts index 44789a7..81266c8 100644 --- a/crates/librtbit/webui/src/http-api.ts +++ b/crates/librtbit/webui/src/http-api.ts @@ -8,6 +8,7 @@ import { DhtStats, ErrorDetails, FoldersConfig, + DownloadsConfig, IndexarrIdentityStatus, IndexarrRecentResponse, IndexarrSearchResponse, @@ -539,6 +540,12 @@ export const API: RtbitAPI & { getVersion: () => Promise } = { setFolders: (config: FoldersConfig): Promise => { return makeRequest("POST", "/session/folders", config, true); }, + getDownloadsConfig: (): Promise => { + return makeRequest("GET", "/session/downloads"); + }, + setDownloadsConfig: (config: DownloadsConfig): Promise => { + return makeRequest("POST", "/session/downloads", config, true); + }, browseDirectory: (path?: string): Promise => { const params = new URLSearchParams(); if (path) params.set("path", path); diff --git a/crates/librtbit/webui/src/mock-api.ts b/crates/librtbit/webui/src/mock-api.ts index 6b14a1e..7afedc5 100644 --- a/crates/librtbit/webui/src/mock-api.ts +++ b/crates/librtbit/webui/src/mock-api.ts @@ -1013,6 +1013,10 @@ export const MockAPI: RtbitAPI & { getVersion: () => Promise } = { completed_folder: null, }), setFolders: async () => {}, + getDownloadsConfig: async () => ({ + incomplete_file_suffix: false, + }), + setDownloadsConfig: async () => {}, browseDirectory: async (path?: string) => ({ current: path ?? "/", parent: From 14727e0f6140d109983b6798b5923ef1414adda3 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 20:00:28 +0000 Subject: [PATCH 8/9] fix(webui): tracker health filters react to health updates (#196 review) Graft-Changeset: cc1424338cc7b39382ae991cecd96f8a356cb914a680927e4f04ea5379bdf4ed9 Graft-Work-Item: WI-2408 Graft-Batch: 3 --- .../src/components/compact/TorrentTable.tsx | 4 ++++ .../src/components/compact/TrackerFilter.tsx | 21 +++++++++++++++++-- .../webui/src/helper/torrentFilters.ts | 14 +++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx index 752f554..ef3824e 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx @@ -9,6 +9,7 @@ import { import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; import { TorrentLimits, TorrentListItem } from "../../api-types"; import { TorrentTableRow } from "./TorrentTableRow"; +import { useTrackerHealthStore } from "../../stores/trackerHealthStore"; import { useUIStore } from "../../stores/uiStore"; import { useColumnStore, ColumnDef, ColumnId } from "../../stores/columnStore"; import { Spinner } from "../Spinner"; @@ -185,6 +186,7 @@ export const TorrentTable: React.FC = ({ const statusFilter = useUIStore((state) => state.statusFilter); const categoryFilter = useUIStore((state) => state.categoryFilter); const trackerFilter = useUIStore((state) => state.trackerFilter); + const trackerHealth = useTrackerHealthStore((state) => state.health); const setSearchQuery = useUIStore((state) => state.setSearchQuery); const setStatusFilter = useUIStore((state) => state.setStatusFilter); const setCategoryFilter = useUIStore((state) => state.setCategoryFilter); @@ -278,6 +280,7 @@ export const TorrentTable: React.FC = ({ statusFilter, categoryFilter, trackerFilter, + trackerHealth, ), ) .sort((a, b) => { @@ -295,6 +298,7 @@ export const TorrentTable: React.FC = ({ statusFilter, categoryFilter, trackerFilter, + trackerHealth, sortColumn, sortDirection, torrentLimits, diff --git a/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx b/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx index df78c77..a6a1b44 100644 --- a/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx +++ b/crates/librtbit/webui/src/components/compact/TrackerFilter.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { BsBroadcast, BsBroadcastPin, @@ -30,6 +30,7 @@ export const TrackerFilter: React.FC = () => { const API = useContext(APIContext); const health = useTrackerHealthStore((state) => state.health); const setHealth = useTrackerHealthStore((state) => state.setHealth); + const [healthChecked, setHealthChecked] = useState(false); // Ids of torrents with trackers; a stable string so polling doesn't restart // on every list refresh. @@ -59,7 +60,10 @@ export const TrackerFilter: React.FC = () => { } }), ); - if (!cancelled) setHealth(next); + if (!cancelled) { + setHealth(next); + setHealthChecked(true); + } }; check(); const timer = setInterval(check, HEALTH_POLL_MS); @@ -79,6 +83,19 @@ export const TrackerFilter: React.FC = () => { return { error, warning }; }, [health]); + // A health group with no torrents has no sidebar item, so a selected (or + // persisted) one would leave an empty list with nothing selected: fall back + // to "All" once health has been checked. + useEffect(() => { + if (!healthChecked) return; + if ( + (trackerFilter === TRACKER_FILTER_ERROR && healthCounts.error === 0) || + (trackerFilter === TRACKER_FILTER_WARNING && healthCounts.warning === 0) + ) { + setTrackerFilter(null); + } + }, [healthChecked, trackerFilter, healthCounts, setTrackerFilter]); + const trackerCounts = useMemo(() => { const counts: Record = {}; let trackerless = 0; diff --git a/crates/librtbit/webui/src/helper/torrentFilters.ts b/crates/librtbit/webui/src/helper/torrentFilters.ts index 738c704..9de22fb 100644 --- a/crates/librtbit/webui/src/helper/torrentFilters.ts +++ b/crates/librtbit/webui/src/helper/torrentFilters.ts @@ -1,7 +1,7 @@ // Shared torrent filtering and sorting utilities import type { TorrentListItem } from "../api-types"; -import { useTrackerHealthStore } from "../stores/trackerHealthStore"; +import type { TrackerHealth } from "../stores/trackerHealthStore"; import { StatusFilter, matchesStatus } from "./statusFilter"; export type { StatusFilter } from "./statusFilter"; @@ -113,16 +113,17 @@ export const TRACKER_FILTER_ERROR = ":error"; export const TRACKER_FILTER_WARNING = ":warning"; // Check if torrent matches tracker filter (a tracker host, "" = trackerless, -// or one of the health groups above) +// or one of the health groups above). Callers pass tracker health so that +// memoised lists re-filter when it changes. export function matchesTracker( t: TorrentListItem, trackerFilter: string | null, + health: Record = {}, ): boolean { if (trackerFilter === null) return true; - if (trackerFilter === TRACKER_FILTER_ERROR) - return useTrackerHealthStore.getState().health[t.id] === "error"; + if (trackerFilter === TRACKER_FILTER_ERROR) return health[t.id] === "error"; if (trackerFilter === TRACKER_FILTER_WARNING) - return useTrackerHealthStore.getState().health[t.id] === "warning"; + return health[t.id] === "warning"; const hosts = torrentTrackerHosts(t); if (trackerFilter === "") return hosts.length === 0; return hosts.includes(trackerFilter); @@ -145,11 +146,12 @@ export function isTorrentVisible( statusFilter: StatusFilter, categoryFilter?: string | null, trackerFilter?: string | null, + trackerHealth?: Record, ): boolean { return ( matchesSearch(t.name, searchQuery) && matchesStatus(t, statusFilter) && matchesCategory(t, categoryFilter ?? null) && - matchesTracker(t, trackerFilter ?? null) + matchesTracker(t, trackerFilter ?? null, trackerHealth) ); } From c57eeb05cbe3846fcd2b99c4e663182de75a1698 Mon Sep 17 00:00:00 2001 From: graft Date: Sat, 24 Oct 2026 20:16:32 +0000 Subject: [PATCH 9/9] fix(webui): gui-e2e regressions from batch 2 (#198/#199/#196) #199's wider default column set (22-column parity) sums past the viewport. In the table-fixed layout the auto-width Name column collapsed to 0px and the pane pushed the page wider than the screen, clipping the toolbar. Give the grid a min-width of its column widths (Name keeps 240px) and let the table pane shrink (min-w-0) so it scrolls sideways instead. Graft-Changeset: c70989a12f1831a0a97835417ffa943462e9cdf43b3508d76279fb5c620a40925 Graft-Session: ses_01M5QRE0MQN4GPFHJK3WQ0J9E8 Graft-Work-Item: WI-2406 Graft-Batch: 3 --- .../src/components/compact/CompactLayout.tsx | 4 ++-- .../src/components/compact/TorrentTable.tsx | 16 ++++++++++++++++ crates/librtbit/webui/src/rtbit-web.tsx | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/librtbit/webui/src/components/compact/CompactLayout.tsx b/crates/librtbit/webui/src/components/compact/CompactLayout.tsx index 4e2223c..8047686 100644 --- a/crates/librtbit/webui/src/components/compact/CompactLayout.tsx +++ b/crates/librtbit/webui/src/components/compact/CompactLayout.tsx @@ -72,8 +72,8 @@ export const CompactLayout: React.FC = ({ const hasTorrents = (torrents?.length ?? 0) > 0; return ( -
-
+
+
{hasTorrents && ( diff --git a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx index ef3824e..62f3da7 100644 --- a/crates/librtbit/webui/src/components/compact/TorrentTable.tsx +++ b/crates/librtbit/webui/src/components/compact/TorrentTable.tsx @@ -166,6 +166,19 @@ function TableColGroup({ columns }: { columns: ColumnDef[] }) { ); } +/** Auto-width columns (Name) keep at least this much room when the table overflows */ +const AUTO_COLUMN_MIN_PX = 240; + +/** Sum of the visible column widths, so the table scrolls sideways rather than squeezing Name to nothing */ +function useTableMinWidth(columns: ColumnDef[]): number { + useColumnStore((s) => s.columnWidths); + const getWidth = useColumnStore((s) => s.getWidth); + return columns.reduce((sum, col) => { + const w = getWidth(col.id); + return sum + (w > 0 ? w : Math.max(col.minWidth, AUTO_COLUMN_MIN_PX)); + }, 0); +} + interface TorrentTableProps { torrents: TorrentListItem[] | null; loading: boolean; @@ -577,6 +590,8 @@ export const TorrentTable: React.FC = ({ setContextMenu({ x: e.clientX, y: e.clientY }); }, []); + const tableMinWidth = useTableMinWidth(visibleColumns); + // The list can shrink under the active row (filter, search), so clamp it into range const focusIndex = Math.min( activeIndex, @@ -663,6 +678,7 @@ export const TorrentTable: React.FC = ({ aria-colcount={visibleColumns.length} onKeyDown={handleGridKeyDown} className="flex flex-col h-full" + style={{ minWidth: tableMinWidth }} > {/* Header */} diff --git a/crates/librtbit/webui/src/rtbit-web.tsx b/crates/librtbit/webui/src/rtbit-web.tsx index 63e3b23..1408bae 100644 --- a/crates/librtbit/webui/src/rtbit-web.tsx +++ b/crates/librtbit/webui/src/rtbit-web.tsx @@ -364,7 +364,7 @@ export const RtbitWebUI = (props: { {/* Main content area */}