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:
parent
95271f065a
commit
b749242583
@ -59,26 +59,44 @@ export class SftpGateway {
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'list': {
|
case 'list': {
|
||||||
this.logger.log(`[SFTP] readdir starting for path: "${msg.path}"`);
|
// Resolve '~' to the user's home directory via SFTP realpath('.')
|
||||||
try {
|
const resolvePath = (path: string, cb: (resolved: string) => void) => {
|
||||||
sftp.readdir(msg.path, (err: any, list: any[]) => {
|
if (path === '~') {
|
||||||
this.logger.log(`[SFTP] readdir callback fired, err=${err?.message || 'null'}, entries=${list?.length || 0}`);
|
sftp.realpath('.', (err: any, absPath: string) => {
|
||||||
if (err) return this.send(client, { type: 'error', message: err.message });
|
if (err) {
|
||||||
const entries = list.map((f: any) => ({
|
this.logger.warn(`[SFTP] realpath('.') failed, falling back to /: ${err.message}`);
|
||||||
name: f.filename,
|
cb('/');
|
||||||
path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`,
|
} else {
|
||||||
size: f.attrs.size,
|
cb(absPath);
|
||||||
isDirectory: (f.attrs.mode & 0o40000) !== 0,
|
}
|
||||||
permissions: (f.attrs.mode & 0o7777).toString(8),
|
});
|
||||||
modified: new Date(f.attrs.mtime * 1000).toISOString(),
|
} else {
|
||||||
}));
|
cb(path);
|
||||||
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) {
|
resolvePath(msg.path, (resolvedPath) => {
|
||||||
this.logger.error(`[SFTP] readdir threw synchronously: ${syncErr.message}`);
|
this.logger.log(`[SFTP] readdir starting for path: "${resolvedPath}"`);
|
||||||
this.send(client, { type: 'error', message: syncErr.message });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'read': {
|
case 'read': {
|
||||||
|
|||||||
@ -49,6 +49,20 @@ export class SshConnectionService {
|
|||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => onData(data.toString('utf-8')));
|
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', () => {
|
stream.on('close', () => {
|
||||||
this.disconnect(sessionId);
|
this.disconnect(sessionId);
|
||||||
onClose('Session ended');
|
onClose('Session ended');
|
||||||
|
|||||||
@ -1,17 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useSftp } from '~/composables/useSftp'
|
import { useSftp } from '~/composables/useSftp'
|
||||||
|
import { useSessionStore } from '~/stores/session.store'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sessions = useSessionStore()
|
||||||
const sessionIdRef = computed(() => props.sessionId)
|
const sessionIdRef = computed(() => props.sessionId)
|
||||||
const {
|
const {
|
||||||
entries, currentPath, fileContent,
|
entries, currentPath, fileContent,
|
||||||
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
|
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
|
||||||
} = useSftp(sessionIdRef)
|
} = 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 width = ref(260) // resizable sidebar width in px
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const newFolderName = ref('')
|
const newFolderName = ref('')
|
||||||
@ -42,7 +57,7 @@ function handleFileSelected(event: Event) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connect()
|
connect()
|
||||||
list('/')
|
list('~') // Backend resolves ~ to user's home directory via SFTP realpath('.')
|
||||||
})
|
})
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
function navigateTo(path: string) {
|
||||||
|
|||||||
@ -58,6 +58,28 @@ export function useTerminal() {
|
|||||||
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) {
|
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}`
|
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/terminal?token=${auth.token}`
|
||||||
ws = new WebSocket(wsUrl)
|
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.onopen = () => {
|
||||||
ws!.send(JSON.stringify({ type: 'connect', hostId }))
|
ws!.send(JSON.stringify({ type: 'connect', hostId }))
|
||||||
@ -67,6 +89,7 @@ export function useTerminal() {
|
|||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
realSessionId = msg.sessionId
|
||||||
// Replace the pending placeholder with the real backend session
|
// Replace the pending placeholder with the real backend session
|
||||||
sessions.replaceSession(pendingSessionId, { key: pendingSessionId, id: msg.sessionId, hostId, hostName, protocol, color, active: true })
|
sessions.replaceSession(pendingSessionId, { key: pendingSessionId, id: msg.sessionId, hostId, hostName, protocol, color, active: true })
|
||||||
// Send initial terminal size
|
// Send initial terminal size
|
||||||
|
|||||||
@ -8,6 +8,7 @@ interface Session {
|
|||||||
protocol: 'ssh' | 'rdp'
|
protocol: 'ssh' | 'rdp'
|
||||||
color: string | null
|
color: string | null
|
||||||
active: boolean
|
active: boolean
|
||||||
|
cwd?: string // current working directory (SSH only, set via OSC 7 shell integration)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = defineStore('sessions', {
|
export const useSessionStore = defineStore('sessions', {
|
||||||
@ -44,5 +45,11 @@ export const useSessionStore = defineStore('sessions', {
|
|||||||
setActive(id: string) {
|
setActive(id: string) {
|
||||||
this.activeSessionId = id
|
this.activeSessionId = id
|
||||||
},
|
},
|
||||||
|
updateCwd(sessionId: string, cwd: string) {
|
||||||
|
const session = this.sessions.find(s => s.id === sessionId)
|
||||||
|
if (session) {
|
||||||
|
session.cwd = cwd
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user