feat: SFTP sidebar — file tree with mock data and transfer progress

Add useSftp composable with mock directory listings, path navigation,
and refresh. FileTree component shows path bar, toolbar (upload,
download, new folder, refresh, delete icons), file entries with
icons, humanized sizes, and dates. TransferProgress component shows
expandable transfer list with progress bars. SidebarToggle now
uses v-model to emit tab changes. MainLayout switches between
ConnectionTree and FileTree based on sidebar tab, and includes
TransferProgress panel. File double-click emits openFile event
for the editor integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 07:04:40 -04:00
parent 4d66849035
commit 8415c98970
5 changed files with 411 additions and 12 deletions

View File

@ -0,0 +1,165 @@
<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"
>
<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 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Download file"
>
<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"
>
<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 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Delete"
>
<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>
<!-- 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"
@dblclick="handleEntryDblClick(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>
<!-- 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 { useSftp, type FileEntry } from "@/composables/useSftp";
const props = defineProps<{
sessionId: string;
}>();
const emit = defineEmits<{
openFile: [entry: FileEntry];
}>();
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
function handleEntryDblClick(entry: FileEntry): void {
if (entry.isDir) {
navigateTo(entry.path);
} else {
emit("openFile", entry);
}
}
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>

View File

@ -0,0 +1,72 @@
<template>
<div class="border-t border-[var(--wraith-border)]">
<!-- Header -->
<button
class="w-full flex items-center justify-between px-3 py-1.5 text-xs text-[var(--wraith-text-muted)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
@click="expanded = !expanded"
>
<span>Transfers ({{ transfers.length }})</span>
<svg
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': expanded }"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M12.78 5.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 6.28a.75.75 0 0 1 1.06-1.06L8 8.94l3.72-3.72a.75.75 0 0 1 1.06 0z" />
</svg>
</button>
<!-- Transfer list -->
<div v-if="expanded && transfers.length > 0" class="px-3 pb-2">
<div
v-for="transfer in transfers"
:key="transfer.id"
class="flex flex-col gap-1 py-1.5"
>
<div class="flex items-center justify-between text-xs">
<span class="text-[var(--wraith-text-secondary)] truncate">
{{ transfer.fileName }}
</span>
<span class="text-[var(--wraith-text-muted)] shrink-0 ml-2">
{{ transfer.speed }}
</span>
</div>
<div class="w-full h-1.5 bg-[var(--wraith-bg-tertiary)] rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="transfer.direction === 'upload' ? 'bg-[var(--wraith-accent-blue)]' : 'bg-[var(--wraith-accent-green)]'"
:style="{ width: transfer.percentage + '%' }"
/>
</div>
<div class="text-[10px] text-[var(--wraith-text-muted)]">
{{ transfer.percentage }}% - {{ transfer.direction === 'upload' ? 'Uploading' : 'Downloading' }}
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="expanded && transfers.length === 0" class="px-3 py-2 text-xs text-[var(--wraith-text-muted)]">
No active transfers
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface Transfer {
id: string;
fileName: string;
percentage: number;
speed: string;
direction: "upload" | "download";
}
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 });
</script>

View File

@ -5,11 +5,11 @@
:key="tab.id"
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
:class="
activeTab === tab.id
modelValue === tab.id
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
"
@click="activeTab = tab.id"
@click="emit('update:modelValue', tab.id)"
>
{{ tab.label }}
</button>
@ -17,12 +17,18 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
export type SidebarTab = "connections" | "sftp";
const tabs = [
{ id: "connections", label: "Connections" },
{ id: "sftp", label: "SFTP" },
] as const;
{ id: "connections" as const, label: "Connections" },
{ id: "sftp" as const, label: "SFTP" },
];
const activeTab = ref<"connections" | "sftp">("connections");
defineProps<{
modelValue: SidebarTab;
}>();
const emit = defineEmits<{
"update:modelValue": [tab: SidebarTab];
}>();
</script>

View File

@ -0,0 +1,100 @@
import { ref, type Ref } from "vue";
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>;
}
/** Mock directory listings used until Wails SFTP bindings are connected. */
const mockDirectories: Record<string, FileEntry[]> = {
"/home/user": [
{ name: "docs", path: "/home/user/docs", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-17" },
{ name: "projects", path: "/home/user/projects", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: ".ssh", path: "/home/user/.ssh", size: 0, isDir: true, permissions: "drwx------", modTime: "2026-03-10" },
{ name: ".bashrc", path: "/home/user/.bashrc", size: 3771, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-15" },
{ name: "deploy.sh", path: "/home/user/deploy.sh", size: 1024, isDir: false, permissions: "-rwxr-xr-x", modTime: "2026-03-16" },
{ name: ".profile", path: "/home/user/.profile", size: 807, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-10" },
],
"/home/user/docs": [
{ name: "readme.md", path: "/home/user/docs/readme.md", size: 2048, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-17" },
{ name: "notes.txt", path: "/home/user/docs/notes.txt", size: 512, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-14" },
],
"/home/user/projects": [
{ name: "app", path: "/home/user/projects/app", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: "Makefile", path: "/home/user/projects/Makefile", size: 256, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-16" },
],
"/home/user/.ssh": [
{ name: "authorized_keys", path: "/home/user/.ssh/authorized_keys", size: 743, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
{ name: "config", path: "/home/user/.ssh/config", size: 128, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
],
};
/**
* Composable that manages SFTP file browsing state.
*
* Uses mock data until Wails SFTPService bindings are connected.
*/
export function useSftp(_sessionId: string): UseSftpReturn {
const currentPath = ref("/home/user");
const entries = ref<FileEntry[]>([]);
const isLoading = ref(false);
const followTerminal = ref(false);
async function listDirectory(path: string): Promise<FileEntry[]> {
// TODO: Replace with Wails binding call — SFTPService.List(sessionId, path)
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 150));
return mockDirectories[path] ?? [];
}
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);
}
// Load initial directory
navigateTo(currentPath.value);
return {
currentPath,
entries,
isLoading,
followTerminal,
navigateTo,
goUp,
refresh,
};
}

View File

@ -33,10 +33,10 @@
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
:style="{ width: sidebarWidth + 'px' }"
>
<SidebarToggle />
<SidebarToggle v-model="sidebarTab" />
<!-- Search -->
<div class="px-3 py-2">
<!-- Search (connections mode only) -->
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
<input
v-model="connectionStore.searchQuery"
type="text"
@ -45,10 +45,28 @@
/>
</div>
<!-- Connection tree -->
<!-- Sidebar content -->
<div class="flex-1 overflow-y-auto">
<ConnectionTree />
<!-- Connection tree -->
<ConnectionTree v-if="sidebarTab === 'connections'" />
<!-- SFTP file tree -->
<template v-else-if="sidebarTab === 'sftp'">
<FileTree
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
:session-id="sessionStore.activeSession.id"
@open-file="handleOpenFile"
/>
<div v-else class="flex items-center justify-center py-8 px-3">
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
Connect to an SSH session to browse files
</p>
</div>
</template>
</div>
<!-- Transfer progress (SFTP mode only) -->
<TransferProgress v-if="sidebarTab === 'sftp'" />
</div>
<!-- Content area -->
@ -56,6 +74,15 @@
<!-- Tab bar -->
<TabBar />
<!-- Editor panel (if a file is open) -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
:file-path="editorFile.path"
:session-id="editorFile.sessionId"
@close="editorFile = null"
/>
<!-- Session area -->
<SessionContainer />
</div>
@ -73,13 +100,42 @@ import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue";
import TabBar from "@/components/session/TabBar.vue";
import SessionContainer from "@/components/session/SessionContainer.vue";
import StatusBar from "@/components/common/StatusBar.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
import type { FileEntry } from "@/composables/useSftp";
const appStore = useAppStore();
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
const sidebarWidth = ref(240);
const sidebarTab = ref<SidebarTab>("connections");
/** Currently open file in the editor panel (null = no file open). */
const editorFile = ref<{ content: string; path: string; sessionId: string } | null>(null);
/** Handle file open from SFTP sidebar — loads mock content for now. */
function handleOpenFile(entry: FileEntry): void {
if (!sessionStore.activeSession) return;
// TODO: Replace with Wails binding call SFTPService.ReadFile(sessionId, entry.path)
// Mock file content for development
const mockContent = `# ${entry.name}\n\n` +
`# File: ${entry.path}\n` +
`# Size: ${entry.size} bytes\n` +
`# Permissions: ${entry.permissions}\n` +
`# Modified: ${entry.modTime}\n\n` +
`# TODO: Content will be loaded from SFTPService.ReadFile()\n`;
editorFile.value = {
content: mockContent,
path: entry.path,
sessionId: sessionStore.activeSession.id,
};
}
</script>