feat(sftp): SFTP sidebar follows terminal CWD

- Inject shell integration (PROMPT_COMMAND/precmd) on SSH connect that
  emits OSC 7 escape sequences reporting the working directory on every
  prompt. Supports bash and zsh.
- Frontend captures OSC 7 via xterm.js parser, updates session store CWD.
- SFTP sidebar watches session CWD and navigates when it changes.
- SFTP starts at ~/ (user home) instead of / on initial connect, resolved
  via SFTP realpath('.') on the backend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 12:44:42 -04:00
parent 95271f065a
commit b749242583
5 changed files with 99 additions and 22 deletions

View File

@ -59,26 +59,44 @@ export class SftpGateway {
switch (msg.type) {
case 'list': {
this.logger.log(`[SFTP] readdir starting for path: "${msg.path}"`);
try {
sftp.readdir(msg.path, (err: any, list: any[]) => {
this.logger.log(`[SFTP] readdir callback fired, err=${err?.message || 'null'}, entries=${list?.length || 0}`);
if (err) return this.send(client, { type: 'error', message: err.message });
const entries = list.map((f: any) => ({
name: f.filename,
path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`,
size: f.attrs.size,
isDirectory: (f.attrs.mode & 0o40000) !== 0,
permissions: (f.attrs.mode & 0o7777).toString(8),
modified: new Date(f.attrs.mtime * 1000).toISOString(),
}));
this.logger.log(`[SFTP] Sending list response with ${entries.length} entries, client.readyState=${client.readyState}`);
this.send(client, { type: 'list', path: msg.path, entries });
});
} catch (syncErr: any) {
this.logger.error(`[SFTP] readdir threw synchronously: ${syncErr.message}`);
this.send(client, { type: 'error', message: syncErr.message });
}
// Resolve '~' to the user's home directory via SFTP realpath('.')
const resolvePath = (path: string, cb: (resolved: string) => void) => {
if (path === '~') {
sftp.realpath('.', (err: any, absPath: string) => {
if (err) {
this.logger.warn(`[SFTP] realpath('.') failed, falling back to /: ${err.message}`);
cb('/');
} else {
cb(absPath);
}
});
} else {
cb(path);
}
};
resolvePath(msg.path, (resolvedPath) => {
this.logger.log(`[SFTP] readdir starting for path: "${resolvedPath}"`);
try {
sftp.readdir(resolvedPath, (err: any, list: any[]) => {
this.logger.log(`[SFTP] readdir callback fired, err=${err?.message || 'null'}, entries=${list?.length || 0}`);
if (err) return this.send(client, { type: 'error', message: err.message });
const entries = list.map((f: any) => ({
name: f.filename,
path: `${resolvedPath === '/' ? '' : resolvedPath}/${f.filename}`,
size: f.attrs.size,
isDirectory: (f.attrs.mode & 0o40000) !== 0,
permissions: (f.attrs.mode & 0o7777).toString(8),
modified: new Date(f.attrs.mtime * 1000).toISOString(),
}));
this.logger.log(`[SFTP] Sending list response with ${entries.length} entries, client.readyState=${client.readyState}`);
this.send(client, { type: 'list', path: resolvedPath, entries });
});
} catch (syncErr: any) {
this.logger.error(`[SFTP] readdir threw synchronously: ${syncErr.message}`);
this.send(client, { type: 'error', message: syncErr.message });
}
});
break;
}
case 'read': {

View File

@ -49,6 +49,20 @@ export class SshConnectionService {
this.sessions.set(sessionId, session);
stream.on('data', (data: Buffer) => onData(data.toString('utf-8')));
// Shell integration: inject PROMPT_COMMAND (bash) / precmd (zsh) to emit
// OSC 7 escape sequences reporting the current working directory on every prompt.
// Leading space prevents the command from being saved to shell history.
// The frontend captures OSC 7 via xterm.js and syncs the SFTP sidebar.
const shellIntegration =
` if [ -n "$ZSH_VERSION" ]; then` +
` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` +
` precmd_functions+=(__wraith_cwd);` +
` elif [ -n "$BASH_VERSION" ]; then` +
` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` +
` fi\n`;
stream.write(shellIntegration);
stream.on('close', () => {
this.disconnect(sessionId);
onClose('Session ended');

View File

@ -1,17 +1,32 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useSftp } from '~/composables/useSftp'
import { useSessionStore } from '~/stores/session.store'
const props = defineProps<{
sessionId: string | null
}>()
const sessions = useSessionStore()
const sessionIdRef = computed(() => props.sessionId)
const {
entries, currentPath, fileContent,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
} = useSftp(sessionIdRef)
// Track the terminal's current working directory from the session store
const sessionCwd = computed(() => {
if (!props.sessionId) return null
return sessions.sessions.find(s => s.id === props.sessionId)?.cwd ?? null
})
// Follow terminal CWD changes navigate SFTP when the shell's directory changes
watch(sessionCwd, (newCwd, oldCwd) => {
if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) {
list(newCwd)
}
})
const width = ref(260) // resizable sidebar width in px
const isDragging = ref(false)
const newFolderName = ref('')
@ -42,7 +57,7 @@ function handleFileSelected(event: Event) {
onMounted(() => {
connect()
list('/')
list('~') // Backend resolves ~ to user's home directory via SFTP realpath('.')
})
function navigateTo(path: string) {

View File

@ -58,6 +58,28 @@ export function useTerminal() {
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/terminal?token=${auth.token}`
ws = new WebSocket(wsUrl)
let realSessionId: string | null = null
// Capture OSC 7 (CWD reporting) from the remote shell.
// Format: file://hostname/absolute/path
// The shell integration injected by the backend emits this on every prompt.
term.parser.registerOscHandler(7, (data) => {
if (!realSessionId) return false
try {
const url = new URL(data)
const path = decodeURIComponent(url.pathname)
if (path) {
sessions.updateCwd(realSessionId, path)
}
} catch {
// Fallback: try to extract path after file://hostname
const match = data.match(/^file:\/\/[^/]*(\/.*?)$/)
if (match?.[1]) {
sessions.updateCwd(realSessionId, match[1])
}
}
return false
})
ws.onopen = () => {
ws!.send(JSON.stringify({ type: 'connect', hostId }))
@ -67,6 +89,7 @@ export function useTerminal() {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'connected':
realSessionId = msg.sessionId
// Replace the pending placeholder with the real backend session
sessions.replaceSession(pendingSessionId, { key: pendingSessionId, id: msg.sessionId, hostId, hostName, protocol, color, active: true })
// Send initial terminal size

View File

@ -8,6 +8,7 @@ interface Session {
protocol: 'ssh' | 'rdp'
color: string | null
active: boolean
cwd?: string // current working directory (SSH only, set via OSC 7 shell integration)
}
export const useSessionStore = defineStore('sessions', {
@ -44,5 +45,11 @@ export const useSessionStore = defineStore('sessions', {
setActive(id: string) {
this.activeSessionId = id
},
updateCwd(sessionId: string, cwd: string) {
const session = this.sessions.find(s => s.id === sessionId)
if (session) {
session.cwd = cwd
}
},
},
})