wraith/src/components/sftp/FileTree.vue
Vantz Stockwell 2d0964f6b2
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
feat: network scanner + SFTP context menu + CI fix
Network scanner (through SSH exec channels):
- scan_network: ping sweep + ARP table + reverse DNS on remote network
- scan_ports: TCP connect scan via bash /dev/tcp (parallel batches of 20)
- quick_scan: 24 common ports (SSH, HTTP, RDP, SMB, DB, etc.)
- Cross-platform: Linux + macOS
- No agent/nmap required — uses standard POSIX commands
- All scans run on the remote host through existing SSH tunnel

SFTP context menu:
- Right-click on files/folders shows Edit, Download, Rename, Delete
- Right-click on folders shows Open Folder
- Teleport menu to body for proper z-index layering
- Click-away handler to close menu
- Rename uses sftp_rename invoke

CI fix:
- Added default-run = "wraith" to Cargo.toml
- The [[bin]] entry for wraith-mcp-bridge confused Cargo about which
  binary is the Tauri app main binary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:56:42 -04:00

385 lines
16 KiB
Vue

<template>
<div class="flex flex-col h-full text-xs">
<!-- Path bar -->
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-tertiary)]">
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer shrink-0"
title="Go up"
@click="goUp"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.22 9.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 0 1-1.06 0z" />
</svg>
</button>
<span class="text-[var(--wraith-text-secondary)] truncate font-mono text-[10px]">
{{ currentPath }}
</span>
</div>
<!-- Toolbar -->
<div class="flex items-center gap-1 px-2 py-1 border-b border-[var(--wraith-border)]">
<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" />
<path d="M11.78 4.72a.75.75 0 0 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25z" />
</svg>
</button>
<button
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" />
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969z" />
</svg>
</button>
<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" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Refresh"
@click="refresh"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.001 7.001 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.501 5.501 0 0 0 8 2.5zM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.001 7.001 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834z" />
</svg>
</button>
<button
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" />
</svg>
</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 -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="text-[var(--wraith-text-muted)]">Loading...</span>
</div>
<!-- Empty -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-8">
<span class="text-[var(--wraith-text-muted)]">Empty directory</span>
</div>
<!-- Entries -->
<template v-else>
<button
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)"
@contextmenu.prevent="openContextMenu($event, entry)"
>
<!-- Icon -->
<svg
v-if="entry.isDir"
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
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.75z" />
</svg>
<svg
v-else
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
</svg>
<!-- Name -->
<span class="text-[var(--wraith-text-primary)] truncate">{{ entry.name }}</span>
<!-- Size (files only) -->
<span
v-if="!entry.isDir"
class="ml-auto text-[var(--wraith-text-muted)] text-[10px] shrink-0"
>
{{ humanizeSize(entry.size) }}
</span>
<!-- Modified date -->
<span class="text-[var(--wraith-text-muted)] text-[10px] shrink-0 w-[68px] text-right">
{{ entry.modTime }}
</span>
</button>
</template>
</div>
<!-- Context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
@click="contextMenu.visible = false"
@contextmenu.prevent
>
<button
v-if="!contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="handleEdit(contextMenu.entry!)"
>
Edit
</button>
<button
v-if="!contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="selectedEntry = contextMenu.entry!; handleDownload()"
>
Download
</button>
<button
v-if="contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="navigateTo(contextMenu.entry!.path)"
>
Open Folder
</button>
<button
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="handleRename(contextMenu.entry!)"
>
Rename
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
@click="selectedEntry = contextMenu.entry!; handleDelete()"
>
Delete
</button>
</div>
</Teleport>
<!-- Click-away handler to close context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed inset-0 z-[99]"
@click="contextMenu.visible = false"
@contextmenu.prevent="contextMenu.visible = false"
/>
</Teleport>
<!-- Follow terminal toggle -->
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
<input
v-model="followTerminal"
type="checkbox"
class="w-3 h-3 accent-[var(--wraith-accent-blue)] cursor-pointer"
/>
<span>Follow terminal folder</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useSftp, type FileEntry } from "@/composables/useSftp";
import { useTransfers } from "@/composables/useTransfers";
const props = defineProps<{
sessionId: string;
}>();
const emit = defineEmits<{
openFile: [entry: FileEntry];
}>();
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);
/** Right-click context menu state. */
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
visible: false, x: 0, y: 0, entry: null,
});
function openContextMenu(event: MouseEvent, entry: FileEntry): void {
selectedEntry.value = entry;
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry };
}
function handleEdit(entry: FileEntry): void {
emit("openFile", entry);
}
async function handleRename(entry: FileEntry): Promise<void> {
const newName = prompt("Rename to:", entry.name);
if (!newName || !newName.trim() || newName.trim() === entry.name) return;
const parentPath = entry.path.substring(0, entry.path.lastIndexOf("/"));
const newPath = parentPath + "/" + newName.trim();
try {
await invoke("sftp_rename", { sessionId: props.sessionId, oldPath: entry.path, newPath });
await refresh();
} catch (err) {
console.error("SFTP rename error:", err);
}
}
/** 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);
}
}
// ── 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 invoke<string>("sftp_read_file", {
sessionId: props.sessionId,
path: entry.path,
});
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);
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
async function handleDelete(): Promise<void> {
if (!selectedEntry.value) return;
const entry = selectedEntry.value;
if (!confirm(`Delete "${entry.name}"?`)) return;
try {
await invoke("sftp_delete", { sessionId: props.sessionId, path: entry.path });
selectedEntry.value = null;
await refresh();
} catch (err) {
console.error("SFTP delete error:", err);
}
}
// ── 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 invoke("sftp_mkdir", { sessionId: props.sessionId, path: fullPath });
await refresh();
} catch (err) {
console.error("SFTP mkdir error:", err);
}
}
// ── 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 invoke("sftp_write_file", {
sessionId: props.sessionId,
path: 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"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
return `${size} ${units[i]}`;
}
</script>