diff --git a/Termind/Features/Sidebar/WindowSidebarView.swift b/Termind/Features/Sidebar/WindowSidebarView.swift index d6ea5731..ca3f4f3b 100644 --- a/Termind/Features/Sidebar/WindowSidebarView.swift +++ b/Termind/Features/Sidebar/WindowSidebarView.swift @@ -70,8 +70,8 @@ struct WindowSidebarView: View { let isEnabled = window.isSidebarModeEnabled(mode) return Button { - guard mode == .navigation || isEnabled else { return } - window.sidebarMode = mode + guard isEnabled else { return } + window.activateSidebarMode(mode) } label: { Group { if #available(macOS 26.0, *), usesSystemGlass, isSelected { diff --git a/Termind/Features/Workspaces/WindowModel.swift b/Termind/Features/Workspaces/WindowModel.swift index 667faa6d..dee21580 100644 --- a/Termind/Features/Workspaces/WindowModel.swift +++ b/Termind/Features/Workspaces/WindowModel.swift @@ -20,7 +20,17 @@ final class WindowModel { } /// The top-level shell has two pinned entries followed by dynamic SSH/SFTP /// Workspaces. This is the single source of truth for workspace selection. - var selectedDestination: WindowDestination = .vault + var selectedDestination: WindowDestination = .vault { + didSet { + if selectedDestination != .assistant { + assistantReturnDestination = selectedDestination + } + } + } + /// Where Navigator returns after the Assistant temporarily replaces the + /// detail surface. Assistant has no row inside the Navigator that could + /// retain the previous selection for us. + private var assistantReturnDestination: WindowDestination = .vault var vaultDestination: VaultDestination = .welcome var vaultWorkRoute: VaultWorkRoute? let assistantLibrary: AssistantLibraryModel @@ -158,6 +168,48 @@ final class WindowModel { sidebar.reconcileMode() } + /// Applies a Sidebar mode chosen by the user, including the two mode-rail + /// buttons that also act as top-level navigation affordances. + /// + /// Direct `sidebarMode` assignment deliberately remains presentation-only + /// for restoration, screenshots, and other programmatic configuration. + func activateSidebarMode(_ mode: SidebarMode) { + guard sidebar.isModeEnabled(mode) else { return } + sidebarMode = mode + + switch mode { + case .assistant: + // Inside a Workspace this is its compact, context-aware Assistant. + // Everywhere else the same control opens the full Assistant route. + if selectedWorkspace == nil { + selectAssistant() + } + case .navigation: + if selectedDestination == .assistant { + restoreAssistantReturnDestination() + } + case .snippets, .history: + break + } + } + + private func restoreAssistantReturnDestination() { + switch assistantReturnDestination { + case .workspace(let id): + if workspaces.contains(where: { $0.id == id }) { + select(id) + } else { + selectVault() + } + case .vault: + selectVault() + case .assistant: + // Defensive only: the remembered destination is updated exclusively + // from non-Assistant routes. + selectVault() + } + } + /// Opens the shared column and selects the current Workspace's assistant. /// Repeating the command is intentionally idempotent; hiding the column is /// the native Toggle Sidebar command's job. @@ -195,6 +247,8 @@ final class WindowModel { self.vaultDestination = vaultDestination self.assistantLibrary = assistantLibrary selectedDestination = workspaces.first.map { .workspace($0.id) } ?? .vault + // Property observers do not run while an instance is being initialized. + assistantReturnDestination = selectedDestination for workspace in workspaces { workspace.lifecycleRouter = self } diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index e4c07ede..9a8e597c 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -2365,6 +2365,10 @@ } } }, + "Choose" : { + "comment" : "A button that opens a file picker to choose a private key.", + "isCommentAutoGenerated" : true + }, "Choose a private key or OpenSSH user certificate file." : { "localizations" : { "zh-Hans" : { @@ -2386,6 +2390,7 @@ } }, "Choose File…" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4197,16 +4202,6 @@ } } }, - "Files and images" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "文件与图片" - } - } - } - }, "Dropped image" : { "localizations" : { "zh-Hans" : { @@ -4948,6 +4943,16 @@ } } }, + "Files and images" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件与图片" + } + } + } + }, "Files are identical" : { "localizations" : { "zh-Hans" : { @@ -5842,16 +5847,6 @@ } } }, - "Import Private Key" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "导入私钥" - } - } - } - }, "Import or generate a key, then create an identity with a username. Private keys stay in the Keychain." : { "localizations" : { "zh-Hans" : { @@ -5862,6 +5857,16 @@ } } }, + "Import Private Key" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入私钥" + } + } + } + }, "In Current Workspace" : { "localizations" : { "zh-Hans" : { @@ -14535,4 +14540,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/TermindTests/WindowStateTests.swift b/TermindTests/WindowStateTests.swift index 34b308fa..15d55b73 100644 --- a/TermindTests/WindowStateTests.swift +++ b/TermindTests/WindowStateTests.swift @@ -321,13 +321,88 @@ struct WindowStateTests { func selectingAssistantDoesNotCreateAConversation() { let window = WindowModel() - window.selectAssistant() + window.activateSidebarMode(.assistant) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.conversations.isEmpty) #expect(window.assistantLibrary.selectedConversation == nil) } + @Test + func assistantAndNavigatorModeRailRoundTripsToThePreviousVaultRoute() { + let conversation = ConversationModel() + let library = AssistantLibraryModel(store: AssistantConversationStore( + conversations: [conversation] + )) + let window = WindowModel(assistantLibrary: library) + window.selectAssistant(conversation.id) + window.openVaultWork(.hostEditor(nil), on: .hosts) + + // The Assistant mode can already be selected after navigation elsewhere; + // pressing its selected button must still activate the Assistant route. + window.sidebarMode = .assistant + window.activateSidebarMode(.assistant) + + #expect(window.selectedDestination == .assistant) + #expect(window.assistantLibrary.selectedConversation === conversation) + + window.activateSidebarMode(.navigation) + + #expect(window.selectedDestination == .vault) + #expect(window.vaultDestination == .hosts) + #expect(window.vaultWorkRoute == .hostEditor(nil)) + } + + @Test + func assistantModeRailStaysCompactInWorkspaceAndNavigatorReturnsThereFromFullScreen() { + let workspace = Workspace.sshSession( + host: Host(name: "Server", host: "server.example") + ) + let window = WindowModel(workspaces: [workspace]) + + window.activateSidebarMode(.assistant) + + #expect(window.sidebarMode == .assistant) + #expect(window.selectedDestination == .workspace(workspace.id)) + + let conversation = window.assistantLibrary.createConversation() + window.openAssistantFullScreen(conversation.id) + window.activateSidebarMode(.navigation) + + #expect(window.sidebarMode == .navigation) + #expect(window.selectedDestination == .workspace(workspace.id)) + } + + @Test + func navigatorFallsBackToVaultWhenTheAssistantReturnWorkspaceWasClosed() { + let workspace = Workspace.sshSession( + host: Host(name: "Server", host: "server.example") + ) + let window = WindowModel(workspaces: [workspace]) + window.openAssistantFullScreen() + + window.close(workspace.id) + window.activateSidebarMode(.navigation) + + #expect(window.workspaces.isEmpty) + #expect(window.selectedDestination == .vault) + } + + @Test + func nonNavigationToolModesAndDirectModeAssignmentDoNotNavigate() { + let window = WindowModel() + window.openPage(.hosts) + + window.sidebarMode = .assistant + #expect(window.selectedDestination == .vault) + + window.activateSidebarMode(.snippets) + #expect(window.selectedDestination == .vault) + + window.activateSidebarMode(.history) + #expect(window.selectedDestination == .vault) + } + @Test func enteringAssistantKeepsTheVolatileConversationSelection() { let conversation = ConversationModel() @@ -338,7 +413,7 @@ struct WindowStateTests { window.selectAssistant(conversation.id) window.selectVault() - window.selectAssistant() + window.activateSidebarMode(.assistant) #expect(window.selectedDestination == .assistant) #expect(window.assistantLibrary.selectedConversation === conversation) @@ -348,7 +423,7 @@ struct WindowStateTests { #expect(draft === window.assistantLibrary.draftConversation) window.selectVault() - window.selectAssistant() + window.activateSidebarMode(.assistant) #expect(window.assistantLibrary.selectedConversation == nil) #expect(draft === window.assistantLibrary.draftConversation) } @@ -364,7 +439,7 @@ struct WindowStateTests { window.selectAssistant(conversation.id) window.showAssistantArchive() window.selectVault() - window.selectAssistant() + window.activateSidebarMode(.assistant) #expect(window.isAssistantArchivePresented) #expect(window.assistantLibrary.selectedConversation === conversation)