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
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:
parent
f9c4e2af35
commit
44c79decf3
@ -157,6 +157,23 @@ impl SshService {
|
||||
// Start remote monitoring if enabled (runs on a separate exec channel)
|
||||
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.
|
||||
// Writes go through Handle::data() so no shared mutex is needed.
|
||||
let sid = session_id.clone();
|
||||
|
||||
@ -208,7 +208,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, toRef } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
||||
import { useTransfers } from "@/composables/useTransfers";
|
||||
@ -221,7 +221,7 @@ const emit = defineEmits<{
|
||||
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();
|
||||
|
||||
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||
|
||||
@ -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 { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
@ -21,20 +21,24 @@ export interface UseSftpReturn {
|
||||
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.
|
||||
* 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 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;
|
||||
let currentSessionId = "";
|
||||
|
||||
async function listDirectory(path: string): Promise<FileEntry[]> {
|
||||
async function listDirectory(sessionId: string, path: string): Promise<FileEntry[]> {
|
||||
try {
|
||||
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
|
||||
return result ?? [];
|
||||
@ -45,10 +49,12 @@ export function useSftp(sessionId: string): UseSftpReturn {
|
||||
}
|
||||
|
||||
async function navigateTo(path: string): Promise<void> {
|
||||
if (!currentSessionId) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
currentPath.value = path;
|
||||
entries.value = await listDirectory(path);
|
||||
sessionPaths[currentSessionId] = path;
|
||||
entries.value = await listDirectory(currentSessionId, path);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@ -68,25 +74,63 @@ export function useSftp(sessionId: string): UseSftpReturn {
|
||||
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);
|
||||
async function switchToSession(sessionId: string): Promise<void> {
|
||||
if (!sessionId) {
|
||||
entries.value = [];
|
||||
return;
|
||||
}
|
||||
}).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(() => {
|
||||
if (currentSessionId) {
|
||||
sessionPaths[currentSessionId] = currentPath.value;
|
||||
}
|
||||
if (unlistenCwd) unlistenCwd();
|
||||
});
|
||||
|
||||
// Load home directory on init
|
||||
navigateTo("/home");
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
|
||||
@ -215,7 +215,6 @@
|
||||
<template v-else-if="sidebarTab === 'sftp'">
|
||||
<template v-if="activeSessionId">
|
||||
<FileTree
|
||||
:key="activeSessionId"
|
||||
:session-id="activeSessionId"
|
||||
class="flex-1 min-h-0"
|
||||
@open-file="handleOpenFile"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user