feat(sftp): wire toolbar buttons and live transfer progress panel
- S-1 Upload: hidden file input, FileReader reads as text, calls SFTP.WriteFile, refreshes listing - S-2 Download: calls SFTP.ReadFile, creates Blob, triggers browser download via temporary <a> element - S-3 Delete: confirm() dialog, calls SFTP.Delete, clears selection, refreshes listing - S-4 New Folder: prompt() dialog, calls SFTP.Mkdir with full path, refreshes listing - S-5 Transfer Progress: new useTransfers composable (module-level singleton) tracks active transfers; TransferProgress consumes it directly — no prop threading required - Added single-click selection state to file entries; download/delete buttons dim when no valid selection exists Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ae628c858
commit
68e3e38d75
@ -21,6 +21,7 @@
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="Upload file"
|
||||
@click="handleUpload"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||
@ -28,8 +29,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
class="p-1 transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
:class="selectedEntry && !selectedEntry.isDir ? 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)]' : 'text-[var(--wraith-text-muted)] opacity-40 cursor-not-allowed'"
|
||||
title="Download file"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||
@ -39,6 +42,7 @@
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-yellow)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="New folder"
|
||||
@click="handleNewFolder"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75zM8.75 8v1.75a.75.75 0 0 1-1.5 0V8H5.5a.75.75 0 0 1 0-1.5h1.75V4.75a.75.75 0 0 1 1.5 0V6.5h1.75a.75.75 0 0 1 0 1.5H8.75z" />
|
||||
@ -54,8 +58,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
class="p-1 transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
:class="selectedEntry ? 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)]' : 'text-[var(--wraith-text-muted)] opacity-40 cursor-not-allowed'"
|
||||
title="Delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM6.5 1.75v1.25h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25zM4.997 6.178a.75.75 0 1 0-1.493.144l.685 7.107A2.25 2.25 0 0 0 6.427 15.5h3.146a2.25 2.25 0 0 0 2.238-2.071l.685-7.107a.75.75 0 1 0-1.493-.144l-.685 7.107a.75.75 0 0 1-.746.715H6.427a.75.75 0 0 1-.746-.715l-.684-7.107z" />
|
||||
@ -63,6 +69,14 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for upload -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
|
||||
<!-- File list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
@ -81,6 +95,8 @@
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer group"
|
||||
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
|
||||
@click="selectedEntry = entry"
|
||||
@dblclick="handleEntryDblClick(entry)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
@ -135,7 +151,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Call } from "@wailsio/runtime";
|
||||
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
||||
import { useTransfers } from "@/composables/useTransfers";
|
||||
|
||||
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
@ -146,15 +167,122 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
|
||||
const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
||||
|
||||
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||
const selectedEntry = ref<FileEntry | null>(null);
|
||||
|
||||
/** Hidden file input element used for the upload flow. */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function handleEntryDblClick(entry: FileEntry): void {
|
||||
if (entry.isDir) {
|
||||
selectedEntry.value = null;
|
||||
navigateTo(entry.path);
|
||||
} else {
|
||||
emit("openFile", entry);
|
||||
}
|
||||
}
|
||||
|
||||
// ── S-2: Download ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDownload(): Promise<void> {
|
||||
if (!selectedEntry.value || selectedEntry.value.isDir) return;
|
||||
|
||||
const entry = selectedEntry.value;
|
||||
const transferId = addTransfer(entry.name, "download");
|
||||
|
||||
try {
|
||||
const content = await Call.ByName(`${SFTP}.ReadFile`, props.sessionId, entry.path) as string;
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = entry.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
completeTransfer(transferId);
|
||||
} catch (err) {
|
||||
console.error("SFTP download error:", err);
|
||||
failTransfer(transferId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── S-3: Delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!selectedEntry.value) return;
|
||||
|
||||
const entry = selectedEntry.value;
|
||||
if (!confirm(`Delete "${entry.name}"?`)) return;
|
||||
|
||||
try {
|
||||
await Call.ByName(`${SFTP}.Delete`, props.sessionId, entry.path);
|
||||
selectedEntry.value = null;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
console.error("SFTP delete error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── S-4: New Folder ──────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNewFolder(): Promise<void> {
|
||||
const folderName = prompt("New folder name:");
|
||||
if (!folderName || !folderName.trim()) return;
|
||||
|
||||
const trimmed = folderName.trim();
|
||||
const fullPath = currentPath.value.replace(/\/$/, "") + "/" + trimmed;
|
||||
|
||||
try {
|
||||
await Call.ByName(`${SFTP}.Mkdir`, props.sessionId, fullPath);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
console.error("SFTP mkdir error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── S-1: Upload ──────────────────────────────────────────────────────────────
|
||||
|
||||
function handleUpload(): void {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function handleFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset input so re-selecting the same file triggers change again
|
||||
input.value = "";
|
||||
|
||||
const transferId = addTransfer(file.name, "upload");
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async () => {
|
||||
const content = reader.result as string;
|
||||
const remotePath = currentPath.value.replace(/\/$/, "") + "/" + file.name;
|
||||
|
||||
try {
|
||||
await Call.ByName(`${SFTP}.WriteFile`, props.sessionId, remotePath, content);
|
||||
completeTransfer(transferId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
console.error("SFTP upload error:", err);
|
||||
failTransfer(transferId);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error("FileReader error reading upload file");
|
||||
failTransfer(transferId);
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function humanizeSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
|
||||
@ -53,20 +53,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
interface Transfer {
|
||||
id: string;
|
||||
fileName: string;
|
||||
percentage: number;
|
||||
speed: string;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
import { useTransfers } from "@/composables/useTransfers";
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
// Placeholder mock transfers — empty by default
|
||||
const transfers = ref<Transfer[]>([]);
|
||||
|
||||
// Exports so parent can push mock transfers for testing if needed
|
||||
defineExpose({ transfers });
|
||||
// Auto-expand when transfers become active, collapse when all are gone
|
||||
const { transfers } = useTransfers();
|
||||
</script>
|
||||
|
||||
40
frontend/src/composables/useTransfers.ts
Normal file
40
frontend/src/composables/useTransfers.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export interface Transfer {
|
||||
id: string;
|
||||
fileName: string;
|
||||
percentage: number;
|
||||
speed: string;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
// Module-level singleton — shared across all components that import this composable.
|
||||
const transfers = ref<Transfer[]>([]);
|
||||
|
||||
let _nextId = 0;
|
||||
|
||||
function addTransfer(fileName: string, direction: "upload" | "download"): string {
|
||||
const id = `transfer-${++_nextId}`;
|
||||
transfers.value.push({ id, fileName, percentage: 0, speed: "", direction });
|
||||
return id;
|
||||
}
|
||||
|
||||
function completeTransfer(id: string): void {
|
||||
const t = transfers.value.find((t) => t.id === id);
|
||||
if (t) {
|
||||
t.percentage = 100;
|
||||
t.speed = "";
|
||||
}
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
transfers.value = transfers.value.filter((t) => t.id !== id);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function failTransfer(id: string): void {
|
||||
transfers.value = transfers.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
export function useTransfers() {
|
||||
return { transfers, addTransfer, completeTransfer, failTransfer };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user