wraith/src/composables/useSftp.ts
Vantz Stockwell a8656b0812 feat: Phase 3 complete — SFTP sidebar with full file operations
Rust SFTP service: russh-sftp client on same SSH connection,
DashMap storage, list/read/write/mkdir/delete/rename/stat ops.
5MB file size guard. Non-fatal SFTP failure (terminal still works).

Vue frontend: FileTree with all toolbar buttons wired (upload,
download, delete, mkdir, refresh), TransferProgress panel,
useSftp composable with CWD following via Tauri events.
MainLayout wired with SFTP sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:46:35 -04:00

100 lines
2.5 KiB
TypeScript

import { ref, onBeforeUnmount, type Ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface FileEntry {
name: string;
path: string;
size: number;
isDir: boolean;
permissions: string;
modTime: string;
}
export interface UseSftpReturn {
currentPath: Ref<string>;
entries: Ref<FileEntry[]>;
isLoading: Ref<boolean>;
followTerminal: Ref<boolean>;
navigateTo: (path: string) => Promise<void>;
goUp: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Composable that manages SFTP file browsing state.
* Calls the Rust SFTP commands via Tauri invoke.
*/
export function useSftp(sessionId: string): UseSftpReturn {
const currentPath = ref("/");
const entries = ref<FileEntry[]>([]);
const isLoading = ref(false);
const followTerminal = ref(true);
// Holds the unlisten function returned by listen() — called on cleanup.
let unlistenCwd: UnlistenFn | null = null;
async function listDirectory(path: string): Promise<FileEntry[]> {
try {
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
return result ?? [];
} catch (err) {
console.error("SFTP list error:", err);
return [];
}
}
async function navigateTo(path: string): Promise<void> {
isLoading.value = true;
try {
currentPath.value = path;
entries.value = await listDirectory(path);
} finally {
isLoading.value = false;
}
}
async function goUp(): Promise<void> {
const parts = currentPath.value.split("/").filter(Boolean);
if (parts.length <= 1) {
await navigateTo("/");
return;
}
parts.pop();
await navigateTo("/" + parts.join("/"));
}
async function refresh(): Promise<void> {
await navigateTo(currentPath.value);
}
// Listen for CWD changes from the Rust backend (OSC 7 tracking).
// listen() returns Promise<UnlistenFn> — store it for cleanup.
listen<string>(`ssh:cwd:${sessionId}`, (event) => {
if (!followTerminal.value) return;
const newPath = event.payload;
if (newPath && newPath !== currentPath.value) {
navigateTo(newPath);
}
}).then((unlisten) => {
unlistenCwd = unlisten;
});
onBeforeUnmount(() => {
if (unlistenCwd) unlistenCwd();
});
// Load home directory on init
navigateTo("/home");
return {
currentPath,
entries,
isLoading,
followTerminal,
navigateTo,
goUp,
refresh,
};
}