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)
|
// 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();
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user