mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 02:13:24 +00:00
feat(downloads): prefill Add modal from clipboard (#10)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user