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>
100 lines
2.5 KiB
TypeScript
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,
|
|
};
|
|
}
|