fix: SFTP preserves position on tab switch + CWD following on macOS
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s

SFTP tab switch fix:
- Removed :key on FileTree that destroyed component on every switch
- useSftp now accepts a reactive Ref<string> sessionId
- Watches sessionId changes and reinitializes without destroying state
- Per-session path memory via sessionPaths map — switching back to a
  tab restores exactly where you were browsing

CWD following fix (macOS + all platforms):
- Injects OSC 7 prompt hook into the shell after SSH connect
- zsh: precmd() emits \e]7;file://host/path\e\\
- bash: PROMPT_COMMAND emits the same sequence
- Sent via the PTY channel so it configures the interactive shell
- The passive OSC 7 parser in the output loop picks it up
- SFTP sidebar auto-navigates to the current working directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 00:41:50 -04:00
parent f9c4e2af35
commit 44c79decf3
4 changed files with 82 additions and 22 deletions

View File

@ -157,6 +157,23 @@ impl SshService {
// Start remote monitoring if enabled (runs on a separate exec channel) // Start remote monitoring if enabled (runs on a separate exec channel)
crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone()); crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone());
// Inject OSC 7 CWD reporting hook into the user's shell.
// This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD).
// Sent via the PTY channel so it configures the interactive shell.
{
let osc7_hook = concat!(
// Detect shell and inject the appropriate hook silently
r#"if [ -n "$ZSH_VERSION" ]; then "#,
r#"precmd() { printf '\033]7;file://%s%s\033\\' "$HOST" "$PWD"; }; "#,
r#"elif [ -n "$BASH_VERSION" ]; then "#,
r#"PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\\\" "$HOSTNAME" "$PWD"'; "#,
r#"fi"#,
"\n"
);
let h = handle.lock().await;
let _ = h.data(channel_id, CryptoVec::from_slice(osc7_hook.as_bytes())).await;
}
// Output reader loop — owns the Channel exclusively. // Output reader loop — owns the Channel exclusively.
// Writes go through Handle::data() so no shared mutex is needed. // Writes go through Handle::data() so no shared mutex is needed.
let sid = session_id.clone(); let sid = session_id.clone();

View File

@ -208,7 +208,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, toRef } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useSftp, type FileEntry } from "@/composables/useSftp"; import { useSftp, type FileEntry } from "@/composables/useSftp";
import { useTransfers } from "@/composables/useTransfers"; import { useTransfers } from "@/composables/useTransfers";
@ -221,7 +221,7 @@ const emit = defineEmits<{
openFile: [entry: FileEntry]; openFile: [entry: FileEntry];
}>(); }>();
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId); const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(toRef(props, 'sessionId'));
const { addTransfer, completeTransfer, failTransfer } = useTransfers(); const { addTransfer, completeTransfer, failTransfer } = useTransfers();
/** Currently selected entry (single-click to select, double-click to open/navigate). */ /** Currently selected entry (single-click to select, double-click to open/navigate). */

View File

@ -1,4 +1,4 @@
import { ref, onBeforeUnmount, type Ref } from "vue"; import { ref, watch, onBeforeUnmount, type Ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@ -21,20 +21,24 @@ export interface UseSftpReturn {
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
// Persist the last browsed path per session so switching tabs restores position
const sessionPaths: Record<string, string> = {};
/** /**
* Composable that manages SFTP file browsing state. * Composable that manages SFTP file browsing state.
* Calls the Rust SFTP commands via Tauri invoke. * Accepts a reactive session ID ref so it reinitializes on tab switch
* without destroying the component.
*/ */
export function useSftp(sessionId: string): UseSftpReturn { export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
const currentPath = ref("/"); const currentPath = ref("/");
const entries = ref<FileEntry[]>([]); const entries = ref<FileEntry[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const followTerminal = ref(true); const followTerminal = ref(true);
// Holds the unlisten function returned by listen() — called on cleanup.
let unlistenCwd: UnlistenFn | null = null; let unlistenCwd: UnlistenFn | null = null;
let currentSessionId = "";
async function listDirectory(path: string): Promise<FileEntry[]> { async function listDirectory(sessionId: string, path: string): Promise<FileEntry[]> {
try { try {
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path }); const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
return result ?? []; return result ?? [];
@ -45,10 +49,12 @@ export function useSftp(sessionId: string): UseSftpReturn {
} }
async function navigateTo(path: string): Promise<void> { async function navigateTo(path: string): Promise<void> {
if (!currentSessionId) return;
isLoading.value = true; isLoading.value = true;
try { try {
currentPath.value = path; currentPath.value = path;
entries.value = await listDirectory(path); sessionPaths[currentSessionId] = path;
entries.value = await listDirectory(currentSessionId, path);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -68,25 +74,63 @@ export function useSftp(sessionId: string): UseSftpReturn {
await navigateTo(currentPath.value); await navigateTo(currentPath.value);
} }
// Listen for CWD changes from the Rust backend (OSC 7 tracking). async function switchToSession(sessionId: string): Promise<void> {
// listen() returns Promise<UnlistenFn> — store it for cleanup. if (!sessionId) {
listen<string>(`ssh:cwd:${sessionId}`, (event) => { entries.value = [];
if (!followTerminal.value) return; return;
const newPath = event.payload;
if (newPath && newPath !== currentPath.value) {
navigateTo(newPath);
} }
}).then((unlisten) => {
unlistenCwd = unlisten; // Save current path for the old session
}); if (currentSessionId) {
sessionPaths[currentSessionId] = currentPath.value;
}
// Unlisten old CWD events
if (unlistenCwd) {
unlistenCwd();
unlistenCwd = null;
}
currentSessionId = sessionId;
// Restore saved path or default to /home
const savedPath = sessionPaths[sessionId] || "/home";
currentPath.value = savedPath;
// Load the directory
isLoading.value = true;
try {
entries.value = await listDirectory(sessionId, savedPath);
} finally {
isLoading.value = false;
}
// Listen for CWD changes on the new session
try {
unlistenCwd = await listen<string>(`ssh:cwd:${sessionId}`, (event) => {
if (!followTerminal.value) return;
const newPath = event.payload;
if (newPath && newPath !== currentPath.value) {
navigateTo(newPath);
}
});
} catch {
// Event listener setup failed — non-fatal
}
}
// React to session ID changes
watch(sessionIdRef, (newId) => {
switchToSession(newId);
}, { immediate: true });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (currentSessionId) {
sessionPaths[currentSessionId] = currentPath.value;
}
if (unlistenCwd) unlistenCwd(); if (unlistenCwd) unlistenCwd();
}); });
// Load home directory on init
navigateTo("/home");
return { return {
currentPath, currentPath,
entries, entries,

View File

@ -215,7 +215,6 @@
<template v-else-if="sidebarTab === 'sftp'"> <template v-else-if="sidebarTab === 'sftp'">
<template v-if="activeSessionId"> <template v-if="activeSessionId">
<FileTree <FileTree
:key="activeSessionId"
:session-id="activeSessionId" :session-id="activeSessionId"
class="flex-1 min-h-0" class="flex-1 min-h-0"
@open-file="handleOpenFile" @open-file="handleOpenFile"