Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/(tabs)/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { haptics } from '@/utils/haptics';
const ALL = 'all';
const ENABLED = 'enabled';

type SortKey = 'seeders' | 'size' | 'name' | 'leechers';
type SortKey = 'seeders' | 'size' | 'name' | 'leechers' | 'date';

const SORT_OPTIONS: Array<{
key: SortKey;
Expand All @@ -66,6 +66,7 @@ const SORT_OPTIONS: Array<{
{ key: 'leechers', labelKey: 'screens.search.sortLeechers', icon: 'arrow-down-outline' },
{ key: 'size', labelKey: 'screens.search.sortSize', icon: 'cube-outline' },
{ key: 'name', labelKey: 'screens.search.sortName', icon: 'text-outline' },
{ key: 'date', labelKey: 'screens.search.sortDate', icon: 'calendar-outline' },
];

const TAG_MATCH_ATTEMPTS = 8;
Expand Down Expand Up @@ -234,6 +235,13 @@ export default function SearchScreen() {
const [pendingAddUrl, setPendingAddUrl] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<SearchResult | null>(null);

// pubDate is a qBit 5.0+ (WebAPI >= 2.11.0) field — hide the option on older
// servers rather than offering a sort that silently does nothing.
const visibleSortOptions = useMemo(
() => SORT_OPTIONS.filter((opt) => opt.key !== 'date' || features.supportsSearchPubDate),
[features.supportsSearchPubDate],
);

// Load remembered plugin/category once at mount. The query text itself is
// deliberately NOT restored — it should reset on a fresh app launch, and
// React state already keeps it intact when just switching tabs within the
Expand Down Expand Up @@ -388,6 +396,16 @@ export default function SearchScreen() {
case 'name':
cmp = (a.fileName || '').localeCompare(b.fileName || '');
break;
case 'date': {
// qBittorrent always sends pubDate, using -1 as the "plugin didn't
// report one" sentinel (same convention as nbLeechers) — never
// actually absent. Normalize any non-positive value to 0 so
// unknown dates group together instead of comparing as -1.
const aDate = a.pubDate && a.pubDate > 0 ? a.pubDate : 0;
const bDate = b.pubDate && b.pubDate > 0 ? b.pubDate : 0;
cmp = aDate - bDate;
break;
}
}
return sortDirection === 'asc' ? cmp : -cmp;
});
Expand Down Expand Up @@ -1013,7 +1031,7 @@ export default function SearchScreen() {
},
]}
>
{SORT_OPTIONS.map((opt) => {
{visibleSortOptions.map((opt) => {
const isActive = sortBy === opt.key;
return (
<TouchableOpacity
Expand Down
3 changes: 2 additions & 1 deletion app/(tabs)/settings/rss-rule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar';
import { PathAutocompleteInput } from '@/components/PathAutocompleteInput';
import { SettingRow } from '@/components/SettingRow';
import { OptionPicker, OptionPickerItem } from '@/components/OptionPicker';
import { MultiSelectPicker, MultiSelectPickerItem } from '@/components/MultiSelectPicker';
Expand Down Expand Up @@ -450,7 +451,7 @@ export default function RssRuleEditorScreen() {
<View style={[styles.separator, { backgroundColor: colors.surfaceOutline }]} />

<SettingRow label={t('screens.rss.savePath')} hint={t('screens.rss.savePathHint')}>
<TextInput
<PathAutocompleteInput
style={[
styles.rowInput,
{
Expand Down
9 changes: 7 additions & 2 deletions app/(tabs)/settings/torrent-defaults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useToast } from '@/context/ToastContext';
import { FocusAwareStatusBar } from '@/components/FocusAwareStatusBar';
import { OptionPicker, OptionPickerItem } from '@/components/OptionPicker';
import { InputModal } from '@/components/InputModal';
import { PathAutocompleteInput } from '@/components/PathAutocompleteInput';
import { PathAutocompleteInput, isWindowsStylePath } from '@/components/PathAutocompleteInput';
import { storageService } from '@/services/storage';
import { applicationApi } from '@/services/api/application';
import { apiClient } from '@/services/api/client';
Expand Down Expand Up @@ -1988,7 +1988,12 @@ export default function TorrentDefaultsScreen() {
placeholder={
// Illustrative only — the field's actual value never gets set
// to this string; an empty save path really is sent as "".
`${defaultSavePath || t('screens.settings.categorySavePathPlaceholderDefault')}/${editCategoryName || editingCategory} ${t('screens.settings.categorySavePathPlaceholderSuffix')}`
(() => {
const base =
defaultSavePath || t('screens.settings.categorySavePathPlaceholderDefault');
const sep = isWindowsStylePath(base) ? '\\' : '/';
return `${base}${sep}${editCategoryName || editingCategory} ${t('screens.settings.categorySavePathPlaceholderSuffix')}`;
})()
}
placeholderTextColor={colors.textSecondary}
/>
Expand Down
45 changes: 35 additions & 10 deletions components/PathAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,42 @@ const BLUR_CLEAR_MS = 150;
* root of whatever drive qBittorrent happens to be running on (typically C:) —
* there's no API to enumerate other drives. But the endpoint does accept a
* drive-letter path like "D:/" directly, so once a user types one, suggestions
* should still kick in instead of silently doing nothing (#180).
* should still kick in instead of silently doing nothing (#180). Accept either
* separator after the colon (`D:/` or `D:\`) since Windows users naturally
* type backslashes.
*/
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:\//;
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[/\\]/;
/** A UNC network path, e.g. `\\nas\share\`. Windows-only; always backslash. */
const UNC_PATH = /^\\\\/;

/**
* True for paths that are unambiguously Windows-style (drive letter or UNC
* share) — the only case where `\` should be treated as a path separator.
* A bare `/`-rooted path (Linux/macOS host) never gets this treatment, so a
* literal backslash in a Linux directory name is never misread as a separator.
*/
export function isWindowsStylePath(text: string): boolean {
return WINDOWS_DRIVE_PATH.test(text) || UNC_PATH.test(text);
}

/** Index of the last path separator, respecting `isWindowsStylePath`. */
function lastSeparatorIndex(text: string): number {
return isWindowsStylePath(text)
? Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
: text.lastIndexOf('/');
}

/**
* qBittorrent's getDirectoryContent returns absolute paths (QDirIterator::next),
* not basenames. Strip to the final segment so we can filter and apply safely.
* On Windows the server returns entries with backslash separators (e.g.
* "F:\test folder") even when queried with a forward-slash dirPath, so both
* separators have to be recognized or the "basename" ends up being the whole
* backslash path (#180).
*/
function toBaseName(entry: string): string {
const trimmed = entry.replace(/\/+$/, '');
const idx = trimmed.lastIndexOf('/');
const trimmed = entry.replace(/[/\\]+$/, '');
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
}

Expand Down Expand Up @@ -112,13 +137,13 @@ export function PathAutocompleteInput({
setSuggestions([]);
return;
}
const lastSlash = text.lastIndexOf('/');
if ((!text.startsWith('/') && !WINDOWS_DRIVE_PATH.test(text)) || lastSlash < 0) {
const lastSep = lastSeparatorIndex(text);
if ((!text.startsWith('/') && !isWindowsStylePath(text)) || lastSep < 0) {
setSuggestions([]);
return;
}
const parentDir = text.slice(0, lastSlash + 1);
const partial = text.slice(lastSlash + 1).toLowerCase();
const parentDir = text.slice(0, lastSep + 1);
const partial = text.slice(lastSep + 1).toLowerCase();
const fetchId = ++fetchIdRef.current;
debounceRef.current = setTimeout(async () => {
try {
Expand Down Expand Up @@ -146,7 +171,7 @@ export function PathAutocompleteInput({

const applySuggestion = (path: string) => {
cancelPendingBlurClear();
const newValue = `${path}/`;
const newValue = `${path}${isWindowsStylePath(path) ? '\\' : '/'}`;
onChangeText(newValue);
// Immediately list the directory just selected instead of leaving the
// dropdown empty until the user types another character.
Expand Down Expand Up @@ -196,7 +221,7 @@ export function PathAutocompleteInput({
<TouchableOpacity
key={path}
style={styles.suggestionRow}
onPressIn={() => applySuggestion(path)}
onPress={() => applySuggestion(path)}
>
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>
{path}
Expand Down
10 changes: 8 additions & 2 deletions components/SearchResultRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/context/ThemeContext';
import { SearchResult } from '@/types/api';
import { formatSize } from '@/utils/format';
import { formatSize, formatDate } from '@/utils/format';
import { resultTrackerLabel } from '@/utils/searchResult';
import { spacing, borderRadius } from '@/constants/spacing';
import { typography } from '@/constants/typography';
Expand Down Expand Up @@ -85,13 +85,19 @@ export function SearchResultRow({
{/* Line 2: health dot + meta */}
<View style={styles.statusRow}>
<View style={[styles.stateDot, { backgroundColor: dotColor }]} />
<Text style={[styles.statusText, { color: colors.textSecondary }]} numberOfLines={1}>
{/* numberOfLines=2, not 1: size/seeders/leechers/host/date can
overflow one line once a date is present — wrap instead of
silently truncating the date off the end. */}
<Text style={[styles.statusText, { color: colors.textSecondary }]} numberOfLines={2}>
{formatSize(result.fileSize)}
{' · '}
<Text style={{ color: colors.success }}>↑{seeders}</Text>
{' · '}
<Text>↓{leechers}</Text>
{host ? ` · ${host}` : ''}
{/* qBittorrent sends -1 (not undefined) when the plugin didn't
report a date — only render when it's a real timestamp. */}
{result.pubDate && result.pubDate > 0 ? ` · ${formatDate(result.pubDate)}` : ''}
</Text>
{/* Chevron hints that the row expands */}
<Ionicons
Expand Down
2 changes: 1 addition & 1 deletion constants/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const CHANGELOG: ChangelogRelease[] = [
items: [
'Global seeding limits (ratio, seeding time, and what happens when reached) can now be set from the Transfer tab',
'Added an Unlimited shortcut to the Max Ratio and Max Seeding Time editors on the Transfer tab',
'File path now suggested from existing torrent paths and support for windows'
'File path now suggested from existing torrent paths and support for windows',
],
},
{
Expand Down
1 change: 1 addition & 0 deletions locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "Kategorie",
"indexerLabel": "Indexer",
"allTrackers": "Alle Indexer",
Expand Down
1 change: 1 addition & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "Category",
"indexerLabel": "Indexer",
"allTrackers": "All indexers",
Expand Down
1 change: 1 addition & 0 deletions locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "Categoría",
"indexerLabel": "Indexador",
"allTrackers": "Todos los indexadores",
Expand Down
1 change: 1 addition & 0 deletions locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "Catégorie",
"indexerLabel": "Indexeur",
"allTrackers": "Tous les indexeurs",
Expand Down
1 change: 1 addition & 0 deletions locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "Категория",
"indexerLabel": "Индексатор",
"allTrackers": "Все индексаторы",
Expand Down
1 change: 1 addition & 0 deletions locales/zh/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@
"sortLeechers": "Leechers",
"sortSize": "Size",
"sortName": "Name",
"sortDate": "Date",
"categoryLabel": "分类",
"indexerLabel": "索引器",
"allTrackers": "所有索引器",
Expand Down
86 changes: 85 additions & 1 deletion tests/rn/components/PathAutocompleteInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,90 @@ describe('PathAutocompleteInput', () => {
expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('D:/', 'dirs');
});

it('normalizes backslash-separated Windows entries to forward-slash suggestions', async () => {
jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['F:\\test folder']);

const onChangeText = jest.fn();
await render(
<PathAutocompleteInput testID="path-input" value="F:/" onChangeText={onChangeText} />,
);

fireEvent.changeText(screen.getByTestId('path-input'), 'F:/');

expect(await screen.findByText('F:/test folder')).toBeTruthy();
expect(screen.queryByText('F:/F:\\test folder')).toBeNull();
});

it('fetches suggestions for a Windows drive-letter path typed with backslashes', async () => {
jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['D:\\Downloads']);

const onChangeText = jest.fn();
await render(
<PathAutocompleteInput testID="path-input" value="D:\\Do" onChangeText={onChangeText} />,
);

fireEvent.changeText(screen.getByTestId('path-input'), 'D:\\Do');

expect(await screen.findByText('D:\\Downloads')).toBeTruthy();
expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('D:\\', 'dirs');
});

it('fetches suggestions for a UNC share path', async () => {
jest
.mocked(applicationApi.getDirectoryContent)
.mockResolvedValue(['\\\\nas\\share\\Downloads']);

const onChangeText = jest.fn();
await render(
<PathAutocompleteInput
testID="path-input"
value="\\\\nas\\share\\Do"
onChangeText={onChangeText}
/>,
);

fireEvent.changeText(screen.getByTestId('path-input'), '\\\\nas\\share\\Do');

expect(await screen.findByText('\\\\nas\\share\\Downloads')).toBeTruthy();
expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('\\\\nas\\share\\', 'dirs');
});

it('appends a backslash (not a forward slash) when a Windows-style suggestion is tapped', async () => {
jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['D:\\Downloads']);

const onChangeText = jest.fn();
await render(
<PathAutocompleteInput testID="path-input" value="D:\\Do" onChangeText={onChangeText} />,
);

fireEvent.changeText(screen.getByTestId('path-input'), 'D:\\Do');
const suggestion = await screen.findByText('D:\\Downloads');

fireEvent(suggestion.parent!, 'press');

expect(onChangeText).toHaveBeenCalledWith('D:\\Downloads\\');
});

it('never treats a literal backslash in a Linux path as a separator', async () => {
jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['/data/weird']);

const onChangeText = jest.fn();
await render(
<PathAutocompleteInput
testID="path-input"
value="/data/weird\\name"
onChangeText={onChangeText}
/>,
);

fireEvent.changeText(screen.getByTestId('path-input'), '/data/weird\\name');

// Give the debounced fetch a chance to have fired.
await new Promise((r) => setTimeout(r, 400));

expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('/data/', 'dirs');
});

it('immediately fetches the next directory level after a suggestion is tapped', async () => {
jest
.mocked(applicationApi.getDirectoryContent)
Expand All @@ -102,7 +186,7 @@ describe('PathAutocompleteInput', () => {
fireEvent.changeText(screen.getByTestId('path-input'), '/da');
const suggestion = await screen.findByText('/data');

fireEvent(suggestion.parent!, 'pressIn');
fireEvent(suggestion.parent!, 'press');

// applySuggestion calls onChangeText synchronously with the new value —
// simulate the parent re-rendering with that value, as a real screen would.
Expand Down
3 changes: 3 additions & 0 deletions tests/utils/apiVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('getApiFeatures', () => {
useAddStoppedEnabledPreference: true,
useStoppedAddParam: true,
supportsGetDirectoryContent: true,
supportsSearchPubDate: true,
});
});

Expand All @@ -53,6 +54,7 @@ describe('getApiFeatures', () => {
expect(features.useAddStoppedEnabledPreference).toBe(false);
expect(features.useStoppedAddParam).toBe(false);
expect(features.supportsGetDirectoryContent).toBe(false);
expect(features.supportsSearchPubDate).toBe(false);
// ratio limit fields only require 2.8+
expect(features.hasRatioLimitFields).toBe(true);
});
Expand All @@ -72,6 +74,7 @@ describe('getApiFeatures', () => {
expect(features.useAddStoppedEnabledPreference).toBe(true);
expect(features.useStoppedAddParam).toBe(true);
expect(features.supportsGetDirectoryContent).toBe(true);
expect(features.supportsSearchPubDate).toBe(true);
});

it('enables v5 features above major version 2 (e.g. 3.0.0)', () => {
Expand Down
2 changes: 2 additions & 0 deletions types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ export interface SearchResult {
descrLink: string;
/** Name of the plugin that produced this result. Absent on older servers. */
engineName?: string;
/** Unix timestamp (seconds) the torrent was published, if the plugin reported one (qBit 5.0+ / WebAPI >= 2.11.0). */
pubDate?: number;
}

export interface SearchResultsResponse {
Expand Down
Loading
Loading