From 6411fecb4f5acd59c0950fe2ab2cd764c1d76b1e Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 19:08:54 -0700 Subject: [PATCH] Implements gui file queue and drag and drop. --- Tasks.md | 12 +- gui/src-tauri/src/commands.rs | 43 ++- gui/src-tauri/src/lib.rs | 1 + gui/src/App.css | 243 +++++++++++++---- gui/src/App.tsx | 497 +++++++++++++++++++++++++--------- 5 files changed, 607 insertions(+), 189 deletions(-) diff --git a/Tasks.md b/Tasks.md index 10518ca..7e24330 100644 --- a/Tasks.md +++ b/Tasks.md @@ -70,7 +70,6 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| -| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature | | [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature | | [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature | | [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration | @@ -93,6 +92,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature | | [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature | +| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Fixed | Feature | | [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature | --- @@ -343,19 +343,19 @@ Implement the Tauri-based GUI application with a clean, responsive layout connec - NUT-006 - + ### Implement GUI File Queue + Drag-and-Drop **ID:** NUT-013 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Add drag-and-drop file support and a queue system for multiple uploads. **Requirements:** -- [ ] Drag-and-drop area -- [ ] File queue list -- [ ] Remove/reorder items +- [x] Drag-and-drop area +- [x] File queue list +- [x] Remove/reorder items **Dependencies:** - NUT-012 diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs index 106ea87..6059dad 100644 --- a/gui/src-tauri/src/commands.rs +++ b/gui/src-tauri/src/commands.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::fs; use std::path::PathBuf; use nextcloud_client::{ @@ -14,6 +15,13 @@ pub struct LoginFlowInitPayload { pub poll_token: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileInfo { + pub path: String, + pub name: String, + pub size: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GuiUploadResult { pub file_path: String, @@ -154,6 +162,26 @@ pub async fn select_files() -> Result, String> { } } +#[tauri::command] +pub fn get_file_info(file_path: String) -> Result { + let path = PathBuf::from(&file_path); + if !path.exists() { + return Err(format!("File '{}' does not exist", file_path)); + } + let metadata = fs::metadata(&path).map_err(|e| e.to_string())?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + Ok(FileInfo { + path: file_path, + name, + size: metadata.len(), + }) +} + #[tauri::command] pub async fn upload_file( file_path: String, @@ -211,7 +239,7 @@ pub async fn upload_file( #[cfg(test)] mod tests { - use super::{GuiUploadResult, LoginFlowInitPayload}; + use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload}; #[test] fn test_login_flow_payload_serialization() { @@ -226,6 +254,19 @@ mod tests { assert_eq!(payload, deserialized); } + #[test] + fn test_file_info_serialization() { + let file_info = FileInfo { + path: "/path/to/document.pdf".to_string(), + name: "document.pdf".to_string(), + size: 2048, + }; + + let json = serde_json::to_string(&file_info).unwrap(); + let deserialized: FileInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(file_info, deserialized); + } + #[test] fn test_gui_upload_result_serialization() { let res = GuiUploadResult { diff --git a/gui/src-tauri/src/lib.rs b/gui/src-tauri/src/lib.rs index 82c08f9..2705297 100644 --- a/gui/src-tauri/src/lib.rs +++ b/gui/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ pub fn run() { commands::poll_login_flow, commands::manual_login, commands::select_files, + commands::get_file_info, commands::upload_file ]) .run(tauri::generate_context!()) diff --git a/gui/src/App.css b/gui/src/App.css index 8d80951..64a1273 100644 --- a/gui/src/App.css +++ b/gui/src/App.css @@ -13,6 +13,7 @@ --danger: #ef4444; --danger-hover: #dc2626; --success: #10b981; + --warning: #f59e0b; --radius: 8px; } @@ -36,7 +37,7 @@ body { display: flex; flex-direction: column; height: 100vh; - max-width: 900px; + max-width: 960px; margin: 0 auto; padding: 16px 20px; } @@ -101,37 +102,6 @@ body { cursor: pointer; } -/* Navigation Tabs */ -.nav-tabs { - display: flex; - gap: 8px; - margin-top: 14px; - margin-bottom: 14px; -} - -.tab-button { - background: var(--surface-color); - color: var(--text-muted); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 8px 16px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; -} - -.tab-button:hover { - color: var(--text-main); - background: var(--surface-card); -} - -.tab-button.active { - background: var(--primary); - color: white; - border-color: var(--primary); -} - /* Content Area */ .main-content { flex: 1; @@ -139,6 +109,7 @@ body { display: flex; flex-direction: column; gap: 14px; + padding-top: 14px; } /* Cards */ @@ -147,7 +118,6 @@ body { border: 1px solid var(--border-color); border-radius: var(--radius); padding: 16px; - margin-bottom: 14px; } .card-title { @@ -164,11 +134,10 @@ body { background: var(--surface-color); border: 2px dashed var(--border-color); border-radius: var(--radius); - padding: 24px 16px; + padding: 26px 16px; text-align: center; cursor: pointer; - margin-bottom: 14px; - transition: border-color 0.2s, background-color 0.2s; + transition: all 0.2s ease-in-out; } .file-dropzone:hover { @@ -176,6 +145,18 @@ body { background: rgba(56, 189, 248, 0.05); } +.file-dropzone.drag-over { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.12); + transform: scale(1.01); + box-shadow: 0 0 16px rgba(56, 189, 248, 0.25); +} + +.dropzone-icon { + font-size: 24px; + margin-bottom: 4px; +} + .dropzone-title { font-size: 15px; font-weight: 600; @@ -188,25 +169,189 @@ body { margin-top: 4px; } -/* File Chips */ -.selected-files-list { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.selected-file-chip { - background: var(--surface-card); - border-radius: 4px; - padding: 4px 8px; - font-size: 12px; +/* Queue List */ +.queue-title-meta { display: flex; align-items: center; + gap: 10px; +} + +.queue-summary-pill { + font-size: 11px; + font-weight: 600; + background: var(--surface-card); + color: var(--accent); + padding: 2px 8px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.queue-actions { + display: flex; gap: 6px; - max-width: 100%; +} + +.empty-queue-hint { + text-align: center; + color: var(--text-muted); + font-size: 13px; + padding: 24px 12px; + border: 1px dashed var(--border-color); + border-radius: var(--radius); +} + +.queue-item-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 280px; + overflow-y: auto; + padding-right: 4px; +} + +.queue-item-row { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 12px; + transition: background-color 0.15s, border-color 0.15s; +} + +.queue-item-row:hover { + border-color: var(--accent); +} + +.queue-item-row.dragging { + opacity: 0.4; + border: 1px dashed var(--accent); +} + +.queue-item-drag-handle { + color: var(--text-muted); + cursor: grab; + font-size: 14px; + user-select: none; +} + +.queue-item-index { + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + min-width: 18px; + text-align: center; +} + +.queue-item-info { + flex: 1; + min-width: 0; +} + +.queue-item-name { + font-weight: 600; + color: var(--text-main); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.queue-item-path { + font-size: 11px; + color: var(--text-muted); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item-error { + font-size: 11px; + color: var(--danger); + margin-top: 2px; +} + +.queue-item-size { + font-size: 12px; + color: var(--text-muted); + min-width: 65px; + text-align: right; +} + +.queue-item-badge { + min-width: 75px; + text-align: center; +} + +.status-tag { + display: inline-block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; +} + +.status-tag.status-pending { + background: rgba(148, 163, 184, 0.15); + color: var(--text-muted); + border: 1px solid var(--border-color); +} + +.status-tag.status-uploading { + background: rgba(56, 189, 248, 0.15); + color: var(--accent); + border: 1px solid var(--accent); +} + +.status-tag.status-done { + background: rgba(16, 185, 129, 0.15); + color: var(--success); + border: 1px solid var(--success); +} + +.status-tag.status-error { + background: rgba(239, 68, 68, 0.15); + color: var(--danger); + border: 1px solid var(--danger); +} + +.queue-item-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.btn-icon { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border-color); + border-radius: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; +} + +.btn-icon:hover:not(:disabled) { + color: var(--text-main); + background: var(--surface-card); + border-color: var(--accent); +} + +.btn-icon:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.btn-icon-danger:hover:not(:disabled) { + color: white; + background: var(--danger); + border-color: var(--danger); } /* Form Controls */ diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 45883cd..47bf646 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { openUrl } from "@tauri-apps/plugin-opener"; import "./App.css"; @@ -11,6 +12,22 @@ interface StoredAccount { is_default: boolean; } +interface FileInfo { + path: string; + name: string; + size: number; +} + +interface QueueItem { + id: string; + path: string; + name: string; + size: number; + status: "pending" | "uploading" | "done" | "error"; + errorMessage?: string; + result?: GuiUploadResult; +} + interface GuiUploadResult { file_path: string; file_name: string; @@ -21,18 +38,23 @@ interface GuiUploadResult { } export function App() { - const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload"); const [accounts, setAccounts] = useState([]); const [selectedAccount, setSelectedAccount] = useState(""); const [toastMessage, setToastMessage] = useState(null); + // File Queue State + const [fileQueue, setFileQueue] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + const [draggedIndex, setDraggedIndex] = useState(null); + // Upload Form State - const [selectedFiles, setSelectedFiles] = useState([]); const [remoteDir, setRemoteDir] = useState("Uploads"); const [createShare, setCreateShare] = useState(true); const [sharePassword, setSharePassword] = useState(""); const [isUploading, setIsUploading] = useState(false); - const [uploadResults, setUploadResults] = useState([]); + + const fileQueueRef = useRef(fileQueue); + fileQueueRef.current = fileQueue; const showToast = (msg: string) => { setToastMessage(msg); @@ -58,28 +80,139 @@ export function App() { } }; + const addPathsToQueue = async (paths: string[]) => { + if (!paths || paths.length === 0) return; + + const existingPaths = new Set(fileQueueRef.current.map((q) => q.path)); + const newItems: QueueItem[] = []; + + for (const p of paths) { + if (existingPaths.has(p)) continue; + existingPaths.add(p); + + try { + const info = await invoke("get_file_info", { filePath: p }); + newItems.push({ + id: `${p}-${Date.now()}-${Math.random()}`, + path: info.path, + name: info.name, + size: info.size, + status: "pending", + }); + } catch { + const name = p.split(/[\\/]/).pop() || p; + newItems.push({ + id: `${p}-${Date.now()}-${Math.random()}`, + path: p, + name, + size: 0, + status: "pending", + }); + } + } + + if (newItems.length > 0) { + setFileQueue((prev) => [...prev, ...newItems]); + showToast(`Added ${newItems.length} file(s) to queue.`); + } + }; + useEffect(() => { loadAccounts(); + + // Listen to Tauri Drag-and-Drop events from OS + let unlisten: (() => void) | undefined; + try { + const appWindow = getCurrentWebviewWindow(); + appWindow.onDragDropEvent((event) => { + if (event.payload.type === "enter" || event.payload.type === "over") { + setIsDragOver(true); + } else if (event.payload.type === "drop") { + setIsDragOver(false); + if (event.payload.paths && event.payload.paths.length > 0) { + addPathsToQueue(event.payload.paths); + } + } else if (event.payload.type === "leave") { + setIsDragOver(false); + } + }).then((fn) => { + unlisten = fn; + }).catch((e) => { + console.warn("Tauri drag-drop listener not active in current mode:", e); + }); + } catch (e) { + console.warn("Tauri getCurrentWebviewWindow not available:", e); + } + + return () => { + if (unlisten) unlisten(); + }; }, []); const handleSelectFiles = async () => { try { const files = await invoke("select_files"); if (files && files.length > 0) { - setSelectedFiles((prev) => Array.from(new Set([...prev, ...files]))); + addPathsToQueue(files); } } catch (e) { showToast(`Error picking files: ${e}`); } }; - const handleRemoveSelectedFile = (path: string) => { - setSelectedFiles((prev) => prev.filter((p) => p !== path)); + const handleRemoveQueueItem = (id: string) => { + setFileQueue((prev) => prev.filter((item) => item.id !== id)); }; - const handleUpload = async () => { - if (selectedFiles.length === 0) { - showToast("Please select at least one file to upload."); + const handleMoveQueueItem = (index: number, direction: "up" | "down") => { + setFileQueue((prev) => { + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= prev.length) return prev; + const copy = [...prev]; + const temp = copy[index]; + copy[index] = copy[targetIndex]; + copy[targetIndex] = temp; + return copy; + }); + }; + + const handleDragStart = (index: number) => { + setDraggedIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + + setFileQueue((prev) => { + const copy = [...prev]; + const [removed] = copy.splice(draggedIndex, 1); + copy.splice(index, 0, removed); + return copy; + }); + setDraggedIndex(index); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + }; + + const handleClearCompleted = () => { + setFileQueue((prev) => prev.filter((item) => item.status !== "done")); + }; + + const handleClearAll = () => { + if (isUploading) { + showToast("Cannot clear queue while uploads are in progress."); + return; + } + setFileQueue([]); + }; + + const handleUploadQueue = async () => { + const pendingItems = fileQueue.filter((item) => item.status === "pending" || item.status === "error"); + if (pendingItems.length === 0) { + showToast("No pending files in the queue to upload."); return; } @@ -89,27 +222,37 @@ export function App() { } setIsUploading(true); - const results: GuiUploadResult[] = []; - for (const filePath of selectedFiles) { + for (const item of pendingItems) { + // Mark as uploading + setFileQueue((prev) => + prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", errorMessage: undefined } : q)) + ); + try { const res = await invoke("upload_file", { - filePath, + filePath: item.path, remoteDir, createShare, sharePassword: sharePassword.trim() ? sharePassword.trim() : null, accountId: selectedAccount ? selectedAccount : null, }); - results.push(res); - } catch (e) { - showToast(`Failed to upload ${filePath}: ${e}`); + + setFileQueue((prev) => + prev.map((q) => (q.id === item.id ? { ...q, status: "done", result: res } : q)) + ); + } catch (err: unknown) { + const errStr = typeof err === "string" ? err : String(err); + setFileQueue((prev) => + prev.map((q) => + q.id === item.id ? { ...q, status: "error", errorMessage: errStr } : q + ) + ); } } - setUploadResults(results); - setSelectedFiles([]); setIsUploading(false); - showToast(`Uploaded ${results.length} file(s) successfully!`); + showToast("Queue processing completed."); }; const handleCopy = (text: string, label: string) => { @@ -131,6 +274,10 @@ export function App() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; + const pendingCount = fileQueue.filter((q) => q.status === "pending").length; + const doneCount = fileQueue.filter((q) => q.status === "done").length; + const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0); + return (
{/* Header */} @@ -164,124 +311,209 @@ export function App() {
- {/* Navigation Tabs */} - - {/* Main Content */}
- {activeTab === "upload" && ( -
- {/* File Dropzone / Picker */} -
-
- 📁 Click here to select file(s) for upload -
-
- Upload any document, image, video, or archive to your Nextcloud -
-
+ {/* File Dropzone */} +
{ + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragOver(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const paths: string[] = []; + for (let i = 0; i < e.dataTransfer.files.length; i++) { + const f = e.dataTransfer.files[i]; + // In desktop webview with path property + if ("path" in f && typeof (f as { path: string }).path === "string") { + paths.push((f as { path: string }).path); + } + } + if (paths.length > 0) { + addPathsToQueue(paths); + } + } + }} + > +
📥
+
+ {isDragOver ? "Drop files now to add to queue" : "Drag and drop files here, or click to browse"} +
+
+ Supports any files: documents, media, archives, and directories +
+
- {/* Selected Files List */} - {selectedFiles.length > 0 && ( -
-
- Selected Files ({selectedFiles.length}) - -
-
- {selectedFiles.map((file) => ( -
- {file} - -
- ))} -
-
- )} - - {/* Upload Options Card */} -
-
Upload Settings
-
- - setRemoteDir(e.target.value)} - placeholder="Uploads or Projects/Folder" - /> -
- -
- -
- - {createShare && ( -
- - setSharePassword(e.target.value)} - placeholder="Leave empty for public access without password" - /> -
+ {/* File Queue List */} +
+
+
+ Upload Queue ({fileQueue.length}) + {fileQueue.length > 0 && ( + + {formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done + )} - -
+
+ {doneCount > 0 && ( + + )} + {fileQueue.length > 0 && ( + + )} +
+
- {/* Upload Results */} - {uploadResults.length > 0 && ( -
-
- Upload Results - + {fileQueue.length === 0 ? ( +
+ Queue is empty. Drop files above or click to select files for uploading. +
+ ) : ( +
+ {fileQueue.map((item, index) => ( +
handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + > +
+ ⋮⋮ +
+
{index + 1}
+ +
+
{item.name}
+
{item.path}
+ {item.errorMessage && ( +
Error: {item.errorMessage}
+ )} +
+ +
{formatBytes(item.size)}
+ +
+ + {item.status.toUpperCase()} + +
+ +
+ + + +
- {uploadResults.map((r, i) => ( -
+ ))} +
+ )} +
+ + {/* Upload Settings */} +
+
Upload Settings
+
+ + setRemoteDir(e.target.value)} + placeholder="Uploads or Projects/Folder" + /> +
+ +
+ +
+ + {createShare && ( +
+ + setSharePassword(e.target.value)} + placeholder="Leave empty for public access without password" + /> +
+ )} + + +
+ + {/* Completed Share Links & Results */} + {fileQueue.some((q) => q.result) && ( +
+
+ Share Links & Direct Downloads +
+ {fileQueue + .filter((q) => q.result) + .map((item) => { + const r = item.result!; + return ( +
{r.file_name} {formatBytes(r.bytes_uploaded)} @@ -320,9 +552,8 @@ export function App() {
)}
- ))} -
- )} + ); + })}
)}