feat: wire SFTP CWD following — listen for OSC 7 events + inject shell hook
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m2s

Two missing halves of CWD tracking:

1. Frontend: useSftp now listens for ssh:cwd:{sessionId} Wails events
   and calls navigateTo() when followTerminal is enabled (default: on).

2. Backend: re-added shell integration injection with stty -echo to
   suppress visible command output. Leading space keeps it out of
   shell history. Handles both bash (PROMPT_COMMAND) and zsh (precmd).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 14:00:24 -04:00
parent c953659852
commit bce77e0932
2 changed files with 42 additions and 7 deletions

View File

@ -1,5 +1,5 @@
import { ref, type Ref } from "vue"; import { ref, onBeforeUnmount, type Ref } from "vue";
import { Call } from "@wailsio/runtime"; import { Call, Events } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService"; const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
@ -30,7 +30,7 @@ export function useSftp(sessionId: 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(false); const followTerminal = ref(true);
async function listDirectory(path: string): Promise<FileEntry[]> { async function listDirectory(path: string): Promise<FileEntry[]> {
try { try {
@ -66,6 +66,28 @@ export function useSftp(sessionId: string): UseSftpReturn {
await navigateTo(currentPath.value); await navigateTo(currentPath.value);
} }
// Listen for CWD changes from the Go backend (OSC 7 tracking)
const cleanupCwd = Events.On(`ssh:cwd:${sessionId}`, (event: any) => {
if (!followTerminal.value) return;
let newPath: string;
if (typeof event === "string") {
newPath = event;
} else if (event?.data && typeof event.data === "string") {
newPath = event.data;
} else if (Array.isArray(event?.data)) {
newPath = String(event.data[0] ?? "");
} else {
return;
}
if (newPath && newPath !== currentPath.value) {
navigateTo(newPath);
}
});
onBeforeUnmount(() => {
if (cleanupCwd) cleanupCwd();
});
// Load home directory on init // Load home directory on init
navigateTo("/home"); navigateTo("/home");

View File

@ -142,10 +142,23 @@ func (s *SSHService) Connect(hostname string, port int, username string, authMet
// Launch goroutine to read stdout and forward data via the output handler // Launch goroutine to read stdout and forward data via the output handler
go s.readLoop(sessionID, stdout) go s.readLoop(sessionID, stdout)
// CWD tracking via OSC 7 is handled passively — the CWDTracker in readLoop // Inject shell integration for CWD tracking (OSC 7).
// parses OSC 7 sequences if the remote shell already emits them. Automatic // Uses stty -echo to suppress the command from appearing in the terminal,
// PROMPT_COMMAND injection is deferred until we have a non-echoing mechanism // then restores echo. A leading space keeps it out of shell history.
// (e.g., writing to a second SSH channel or modifying .bashrc). go func() {
time.Sleep(500 * time.Millisecond)
// Suppress echo, set PROMPT_COMMAND for bash (zsh uses precmd),
// restore echo, then clear the current line so no visual artifact remains.
injection := " stty -echo 2>/dev/null; " +
ShellIntegrationCommand("bash") + "; " +
"if [ -n \"$ZSH_VERSION\" ]; then " + ShellIntegrationCommand("zsh") + "; fi; " +
"stty echo 2>/dev/null\n"
sshSession.mu.Lock()
if sshSession.Stdin != nil {
_, _ = sshSession.Stdin.Write([]byte(injection))
}
sshSession.mu.Unlock()
}()
return sessionID, nil return sessionID, nil
} }