Implements gui file queue and drag and drop.

This commit is contained in:
2026-08-23 19:08:54 -07:00
parent ae84a31ed6
commit 6411fecb4f
5 changed files with 607 additions and 189 deletions

View File

@@ -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
<a id="nut-013" class="task" data-status="triage" data-task-type="feature"></a>
<a id="nut-013" class="task" data-status="done" data-task-type="feature"></a>
### 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

View File

@@ -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<Vec<String>, String> {
}
}
#[tauri::command]
pub fn get_file_info(file_path: String) -> Result<FileInfo, String> {
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 {

View File

@@ -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!())

View File

@@ -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 */

View File

@@ -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<StoredAccount[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>("");
const [toastMessage, setToastMessage] = useState<string | null>(null);
// File Queue State
const [fileQueue, setFileQueue] = useState<QueueItem[]>([]);
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// Upload Form State
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [remoteDir, setRemoteDir] = useState<string>("Uploads");
const [createShare, setCreateShare] = useState<boolean>(true);
const [sharePassword, setSharePassword] = useState<string>("");
const [isUploading, setIsUploading] = useState<boolean>(false);
const [uploadResults, setUploadResults] = useState<GuiUploadResult[]>([]);
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<FileInfo>("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<string[]>("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<GuiUploadResult>("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 (
<div className="app-container">
{/* Header */}
@@ -164,63 +311,146 @@ export function App() {
</div>
</header>
{/* Navigation Tabs */}
<nav className="nav-tabs">
<button
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
onClick={() => setActiveTab("upload")}
>
Upload & Share
</button>
</nav>
{/* Main Content */}
<main className="main-content">
{activeTab === "upload" && (
<div>
{/* File Dropzone / Picker */}
<div className="file-dropzone" onClick={handleSelectFiles}>
{/* File Dropzone */}
<div
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
onClick={handleSelectFiles}
onDragOver={(e) => {
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);
}
}
}}
>
<div className="dropzone-icon">📥</div>
<div className="dropzone-title">
📁 Click here to select file(s) for upload
{isDragOver ? "Drop files now to add to queue" : "Drag and drop files here, or click to browse"}
</div>
<div className="dropzone-subtitle">
Upload any document, image, video, or archive to your Nextcloud
Supports any files: documents, media, archives, and directories
</div>
</div>
{/* Selected Files List */}
{selectedFiles.length > 0 && (
{/* File Queue List */}
<div className="card">
<div className="card-title">
<span>Selected Files ({selectedFiles.length})</span>
<div className="queue-title-meta">
<span>Upload Queue ({fileQueue.length})</span>
{fileQueue.length > 0 && (
<span className="queue-summary-pill">
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
</span>
)}
</div>
<div className="queue-actions">
{doneCount > 0 && (
<button
className="btn btn-secondary btn-sm"
onClick={() => setSelectedFiles([])}
onClick={handleClearCompleted}
disabled={isUploading}
>
Clear Completed
</button>
)}
{fileQueue.length > 0 && (
<button
className="btn btn-secondary btn-sm"
onClick={handleClearAll}
disabled={isUploading}
>
Clear All
</button>
)}
</div>
<div className="selected-files-list">
{selectedFiles.map((file) => (
<div key={file} className="selected-file-chip">
<span>{file}</span>
</div>
{fileQueue.length === 0 ? (
<div className="empty-queue-hint">
Queue is empty. Drop files above or click to select files for uploading.
</div>
) : (
<div className="queue-item-list">
{fileQueue.map((item, index) => (
<div
key={item.id}
className={`queue-item-row status-${item.status} ${draggedIndex === index ? "dragging" : ""}`}
draggable={!isUploading}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
>
<div className="queue-item-drag-handle" title="Drag to reorder">
⋮⋮
</div>
<div className="queue-item-index">{index + 1}</div>
<div className="queue-item-info">
<div className="queue-item-name">{item.name}</div>
<div className="queue-item-path">{item.path}</div>
{item.errorMessage && (
<div className="queue-item-error">Error: {item.errorMessage}</div>
)}
</div>
<div className="queue-item-size">{formatBytes(item.size)}</div>
<div className="queue-item-badge">
<span className={`status-tag status-${item.status}`}>
{item.status.toUpperCase()}
</span>
</div>
<div className="queue-item-controls">
<button
className="btn btn-sm"
style={{ border: "none", color: "var(--danger)" }}
onClick={(e) => {
e.stopPropagation();
handleRemoveSelectedFile(file);
}}
className="btn-icon"
title="Move Up"
disabled={isUploading || index === 0}
onClick={() => handleMoveQueueItem(index, "up")}
>
▲
</button>
<button
className="btn-icon"
title="Move Down"
disabled={isUploading || index === fileQueue.length - 1}
onClick={() => handleMoveQueueItem(index, "down")}
>
▼
</button>
<button
className="btn-icon btn-icon-danger"
title="Remove from queue"
disabled={isUploading}
onClick={() => handleRemoveQueueItem(item.id)}
>
✕
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Upload Options Card */}
{/* Upload Settings */}
<div className="card">
<div className="card-title">Upload Settings</div>
<div className="form-group">
@@ -261,27 +491,29 @@ export function App() {
<button
className="btn btn-primary"
style={{ width: "100%", marginTop: 8 }}
onClick={handleUpload}
disabled={isUploading || selectedFiles.length === 0}
onClick={handleUploadQueue}
disabled={isUploading || pendingCount === 0}
>
{isUploading ? "Uploading..." : `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length} files)` : ""}`}
{isUploading
? "Uploading Queue..."
: pendingCount > 0
? `Upload Queue (${pendingCount} pending files)`
: "All Files in Queue Uploaded"}
</button>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
{/* Completed Share Links & Results */}
{fileQueue.some((q) => q.result) && (
<div className="card">
<div className="card-title">
<span>Upload Results</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => setUploadResults([])}
>
Dismiss
</button>
<span>Share Links & Direct Downloads</span>
</div>
{uploadResults.map((r, i) => (
<div key={i} className="result-card">
{fileQueue
.filter((q) => q.result)
.map((item) => {
const r = item.result!;
return (
<div key={item.id} className="result-card">
<div className="result-header">
<span className="result-file-name">{r.file_name}</span>
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
@@ -320,9 +552,8 @@ export function App() {
</div>
)}
</div>
))}
</div>
)}
);
})}
</div>
)}
</main>