feat(downloads): prefill Add modal from clipboard (#10)

This commit is contained in:
NimBold
2026-07-11 09:01:44 +03:30
parent 629a34d1e8
commit ba70662165
9 changed files with 423 additions and 12 deletions
+66 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
@@ -18,6 +18,7 @@ import {
startActionLabel
} from '../utils/downloadActions';
import { isActiveDownloadStatus } from '../utils/downloads';
import { readClipboardDownloadUrls } from '../utils/clipboard';
interface DownloadTableProps {
filter: SidebarFilter;
@@ -27,9 +28,19 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
const { downloads, queues, assignToQueue, openDeleteModal, redownload } = useDownloadStore();
const { addToast } = useToast();
const isMac = navigator.userAgent.includes('Mac');
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
const clipboardReadInFlightRef = useRef(false);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const [animationParent] = useAutoAnimate<HTMLDivElement>();
@@ -355,6 +366,52 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
const handleAddDownload = async () => {
if (clipboardReadInFlightRef.current) return;
clipboardReadInFlightRef.current = true;
setIsReadingClipboard(true);
const store = useDownloadStore.getState();
const initialModalState = {
isOpen: store.isAddModalOpen,
requestVersion: store.pendingAddRequestVersion,
};
try {
const urls = await readClipboardDownloadUrls();
if (!isMountedRef.current) return;
const currentStore = useDownloadStore.getState();
// Do not append a late clipboard result to a newer extension, deep-link,
// paste, or modal request that arrived while the OS clipboard was read.
if (
currentStore.isAddModalOpen !== initialModalState.isOpen ||
currentStore.pendingAddRequestVersion !== initialModalState.requestVersion
) {
return;
}
if (urls.length > 0) {
currentStore.openAddModalWithUrls(urls.join('\n'));
} else {
currentStore.toggleAddModal(true);
}
} catch (error) {
console.warn('Could not read clipboard for Add Download:', error);
if (!isMountedRef.current) return;
const currentStore = useDownloadStore.getState();
if (
currentStore.isAddModalOpen === initialModalState.isOpen &&
currentStore.pendingAddRequestVersion === initialModalState.requestVersion
) {
currentStore.toggleAddModal(true);
}
} finally {
clipboardReadInFlightRef.current = false;
if (isMountedRef.current) setIsReadingClipboard(false);
}
};
const getCategoryIcon = (category: string) => {
switch(category) {
@@ -378,7 +435,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
<div className="main-control-group">
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
<button
className="main-control-button primary"
onClick={() => void handleAddDownload()}
disabled={isReadingClipboard}
aria-busy={isReadingClipboard}
title="Add Download"
>
<Plus size={16} />
</button>
+10
View File
@@ -93,6 +93,16 @@ describe('useDownloadStore', () => {
});
});
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
const initialVersion = useDownloadStore.getState().pendingAddRequestVersion;
useDownloadStore.getState().toggleAddModal(true);
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 1);
useDownloadStore.getState().toggleAddModal(false);
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
});
it('normalizes proxy settings for download dispatch', async () => {
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
+6 -3
View File
@@ -484,7 +484,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
}),
closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }),
toggleAddModal: (isOpen) => set({
toggleAddModal: (isOpen) => set((state) => ({
isAddModalOpen: isOpen,
pendingAddUrls: '',
pendingAddReferer: '',
@@ -492,8 +492,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddHeaders: '',
pendingAddCookies: '',
pendingAddMediaUrls: [],
pendingAddRequestContexts: {}
}),
pendingAddRequestContexts: {},
// Invalidate any in-flight Add-modal handoff even when the modal is
// opened or closed without URLs.
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
})),
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
const existingUrls = isAppending ? state.pendingAddUrls : '';
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readClipboardDownloadUrls } from './clipboard';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
readText: vi.fn(),
}));
describe('clipboard URL extraction', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('reads only supported, unique download URLs from clipboard text', async () => {
vi.mocked(readText).mockResolvedValue(
'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin mailto:user@example.com'
);
await expect(readClipboardDownloadUrls()).resolves.toEqual([
'https://example.com/file.zip',
'ftp://example.com/file.bin',
]);
});
it('preserves clipboard read failures for the caller to handle', async () => {
const error = new Error('clipboard unavailable');
vi.mocked(readText).mockRejectedValue(error);
await expect(readClipboardDownloadUrls()).rejects.toBe(error);
});
});
+7
View File
@@ -0,0 +1,7 @@
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import { extractValidDownloadUrls } from './url';
export const readClipboardDownloadUrls = async (): Promise<string[]> => {
const clipboardText = await readText();
return extractValidDownloadUrls(clipboardText);
};