From 339665e6fb3ea3c64aa3f8b7895fcc41aa2c29ee Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 25 Aug 2026 12:28:40 +0800 Subject: [PATCH] fix: sync SFTP directory context --- Termind/Features/SFTP/SFTPSessionModel.swift | 12 ++++++- Termind/Features/Tabs/Workspace.swift | 18 +++++++++- TermindTests/WindowStateTests.swift | 36 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 601f75bb..17814e90 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -1,5 +1,9 @@ import Foundation +protocol SFTPSessionNavigationRouting: AnyObject { + func sftpSession(_ sftpSession: SFTPSessionModel, didChangePath path: String) +} + /// State consumed by SwiftUI's macOS `Table` must change as one value. /// /// `Table` is backed by `NSOutlineView`. Publishing its rows, filter, and @@ -64,7 +68,12 @@ final class SFTPSessionModel: Identifiable { let id = UUID() let hostID: Host.ID let initialPath: String - private(set) var path: String + private(set) var path: String { + didSet { + guard path != oldValue else { return } + navigationRouter?.sftpSession(self, didChangePath: path) + } + } var pathText: String private(set) var backHistory: [String] = [] private(set) var forwardHistory: [String] = [] @@ -117,6 +126,7 @@ final class SFTPSessionModel: Identifiable { private var hasConnectedOnce = false private var isRecoveringConnection = false private var operationCancellation: TransferCancellationToken? + weak var navigationRouter: (any SFTPSessionNavigationRouting)? private weak var statusStore: HostStore? private weak var transferCoordinator: SFTPTransferCoordinator? private var didResolveInitialPath: Bool diff --git a/Termind/Features/Tabs/Workspace.swift b/Termind/Features/Tabs/Workspace.swift index 5f6cb5a5..e513bbfc 100644 --- a/Termind/Features/Tabs/Workspace.swift +++ b/Termind/Features/Tabs/Workspace.swift @@ -221,30 +221,46 @@ final class Workspace: Identifiable { } } - /// Keeps the sshSession-to-Workspace relationship correct as panes are merged, + /// Keeps each session-to-Workspace relationship correct as panes are merged, /// detached, replaced, and tiled. Removed sessions are cleared only when /// this Workspace still owns their route; a destination may already have /// claimed them during a tree rewrite. private func synchronizeInputRouters(previousLayout: SplitNode? = nil) { let currentSessions = broadcastTargets let currentIDs = Set(currentSessions.map(\.id)) + let currentSFTPSessions = panes.compactMap(\.sftpSession) + let currentSFTPIDs = Set(currentSFTPSessions.map(\.id)) if let previousLayout { for sshSession in previousLayout.panes.compactMap(\.sshSession) where !currentIDs.contains(sshSession.id) && sshSession.inputRouter === self { sshSession.inputRouter = nil } + for sftpSession in previousLayout.panes.compactMap(\.sftpSession) + where !currentSFTPIDs.contains(sftpSession.id) && sftpSession.navigationRouter === self { + sftpSession.navigationRouter = nil + } } for sshSession in currentSessions { sshSession.inputRouter = self } + for sftpSession in currentSFTPSessions { + sftpSession.navigationRouter = self + } if !canBroadcast { broadcastsInput = false } } } +extension Workspace: SFTPSessionNavigationRouting { + func sftpSession(_ sftpSession: SFTPSessionModel, didChangePath path: String) { + guard focusedPane.sftpSession === sftpSession else { return } + lifecycleRouter?.workspaceContextDidChange(self) + } +} + extension Workspace: SSHSessionInputRouting { /// Copies the exact bytes Ghostty produced for a keyboard or paste action. /// Sending directly preserves each event's terminal encoding and avoids diff --git a/TermindTests/WindowStateTests.swift b/TermindTests/WindowStateTests.swift index 47d82dd6..23a45150 100644 --- a/TermindTests/WindowStateTests.swift +++ b/TermindTests/WindowStateTests.swift @@ -110,6 +110,42 @@ struct WindowStateTests { #expect(window.sidebar.assistantDraft.contextChips.map(\.label) == ["Files", "/srv/app"]) } + @Test + func focusedSFTPPathChangesUpdateAssistantContext() { + let host = Host(name: "Files", host: "files.example") + let workspace = Workspace.sftpSession(host: host, initialPath: "/srv") + let sftpSession = workspace.focusedPane.sftpSession! + let window = WindowModel(tabs: [workspace]) + window.hostStore = HostStore(hosts: [host]) + + sftpSession.navigate(to: "/srv/releases") + + #expect(window.sidebar.assistantDraft.contextChips.map(\.kind) == [.host, .directory]) + #expect(window.sidebar.assistantDraft.contextChips.map(\.label) == ["Files", "/srv/releases"]) + + sftpSession.goBack() + #expect(window.sidebar.assistantDraft.contextChips.map(\.label) == ["Files", "/srv"]) + } + + @Test + func backgroundSFTPPathDoesNotReplaceFocusedPaneContext() { + let host = Host(name: "Files", host: "files.example") + let workspace = Workspace.sftpSession(host: host, initialPath: "/focused") + let focusedPane = workspace.focusedPane + workspace.splitFocusedPane( + axis: .horizontal, + with: .sftpSession(host: host, initialPath: "/background") + ) + let backgroundSession = workspace.focusedPane.sftpSession! + workspace.focus(focusedPane.id) + let window = WindowModel(tabs: [workspace]) + window.hostStore = HostStore(hosts: [host]) + + backgroundSession.navigate(to: "/background/changed") + + #expect(window.sidebar.assistantDraft.contextChips.map(\.label) == ["Files", "/focused"]) + } + @Test func openingSidebarConversationRefreshesPinnedContextAndPreservesAttachments() { let host = Host(name: "Server", host: "server.example")