diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 71c7c22d..723fae30 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -3439,22 +3439,6 @@ } } }, - "Ask Assistant" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "询问助手" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "詢問助手" - } - } - } - }, "Ask anything…" : { "localizations" : { "zh-Hans" : { @@ -3471,6 +3455,22 @@ } } }, + "Ask Assistant" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "询问助手" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "詢問助手" + } + } + } + }, "Assigned roles will return to their defaults." : { "comment" : "A message displayed in a confirmation dialog that warns of the consequences of deleting a provider.", "isCommentAutoGenerated" : true, @@ -6070,6 +6070,7 @@ } }, "Connection error" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11657,6 +11658,7 @@ } }, "Hide" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11738,22 +11740,6 @@ } } }, - "Hide Session Details" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "隐藏会话详情" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "隱藏工作階段詳細資訊" - } - } - } - }, "Hide Secret" : { "localizations" : { "zh-Hans" : { @@ -11770,6 +11756,22 @@ } } }, + "Hide Session Details" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏会话详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏工作階段詳細資訊" + } + } + } + }, "Hide tag suggestions" : { "localizations" : { "zh-Hans" : { @@ -22443,6 +22445,7 @@ } }, "Show" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -22640,22 +22643,6 @@ } } }, - "Show Session Details" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "显示会话详情" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "顯示工作階段詳細資訊" - } - } - } - }, "Show Secret" : { "localizations" : { "zh-Hans" : { @@ -22672,6 +22659,22 @@ } } }, + "Show Session Details" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示会话详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示工作階段詳細資訊" + } + } + } + }, "Show tag suggestions" : { "localizations" : { "zh-Hans" : { @@ -23774,6 +23777,7 @@ } }, "Startup Snippet" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -30405,4 +30409,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/TFI/Features/Terminal/MobileCommandHistoryView.swift b/TFI/Features/Terminal/MobileCommandHistoryView.swift index b90019e0..f1b7867e 100644 --- a/TFI/Features/Terminal/MobileCommandHistoryView.swift +++ b/TFI/Features/Terminal/MobileCommandHistoryView.swift @@ -1,14 +1,26 @@ import SwiftUI +import TermindApplication import TermindDomain import TermindPresentation import UIKit +nonisolated enum MobileCommandHistorySnippetFactory { + static func make(name: String, command: String) -> Snippet? { + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + return Snippet(title: name, body: command) + } +} + struct MobileCommandHistoryView: View { + let library: HostLibraryModel let workspace: MobileWorkspace @Environment(\.dismiss) private var dismiss @Environment(\.locale) private var locale @State private var query = "" @State private var clearing = false + @State private var snippetEntry: CommandHistoryEntry? + @State private var snippetName = "" private var entries: [CommandHistoryEntry] { _ = workspace.commandHistory.revision @@ -38,6 +50,10 @@ struct MobileCommandHistoryView: View { } Button("Insert", systemImage: "text.insert") { insert(entry) } .disabled(!workspace.connected) + Button("Save as Snippet", systemImage: "curlybraces") { + beginSavingSnippet(entry) + } + Divider() Button("Delete", systemImage: "trash", role: .destructive) { workspace.commandHistory.delete(entry.id) } @@ -76,6 +92,24 @@ struct MobileCommandHistoryView: View { .confirmationDialog("Clear History?", isPresented: $clearing, titleVisibility: .visible) { Button("Clear", role: .destructive) { workspace.commandHistory.clear(for: workspace.descriptor.hostID) } } + .alert( + "Save as Snippet", + isPresented: Binding( + get: { snippetEntry != nil }, + set: { if !$0 { cancelSavingSnippet() } } + ) + ) { + TextField("Snippet Name", text: $snippetName.singleLineText, axis: .horizontal) + .singleLinePaste() + .lineLimit(1) + Button("Cancel", role: .cancel, action: cancelSavingSnippet) + Button("Save", action: saveSnippet) + .disabled(snippetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + if let snippetEntry { + Text(CommandHistoryPresentation.title(for: snippetEntry)) + } + } }.mobileSheetPresentation(compact: true) .onDisappear { workspace.terminal.requestFocus() } } @@ -104,4 +138,24 @@ struct MobileCommandHistoryView: View { dismiss() workspace.insertTerminalText(entry.command) } + + private func beginSavingSnippet(_ entry: CommandHistoryEntry) { + snippetName = "" + snippetEntry = entry + } + + private func cancelSavingSnippet() { + snippetEntry = nil + snippetName = "" + } + + private func saveSnippet() { + guard let snippetEntry, + let snippet = MobileCommandHistorySnippetFactory.make( + name: snippetName, command: snippetEntry.command + ) + else { return } + library.upsert(snippet) + cancelSavingSnippet() + } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 1877be2c..61f3e3ee 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -231,7 +231,7 @@ struct MobileWorkspaceView: View { case .snippets: MobileTerminalSnippetsView(library: library, workspace: workspace) case .commandHistory: - MobileCommandHistoryView(workspace: workspace) + MobileCommandHistoryView(library: library, workspace: workspace) case .sftpHistory: MobileSFTPHistoryView(workspace: workspace, open: reopenHistory) } diff --git a/TFITests/MobileSnippetTests.swift b/TFITests/MobileSnippetTests.swift index 477d5bf9..968c117d 100644 --- a/TFITests/MobileSnippetTests.swift +++ b/TFITests/MobileSnippetTests.swift @@ -11,6 +11,19 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileSnippetTests { + @Test func historySnippetFactoryTrimsTheNameAndPreservesCommandDefaults() throws { + let command = "printf first\nprintf second" + let snippet = try #require( + MobileCommandHistorySnippetFactory.make(name: " Deploy ", command: command) + ) + + #expect(snippet.title == "Deploy") + #expect(snippet.body == command) + #expect(snippet.packageID == nil) + #expect(snippet.targetHostIDs.isEmpty) + #expect(MobileCommandHistorySnippetFactory.make(name: " \n ", command: command) == nil) + } + @Test func legacyTimestampFallbackIsStableUntilARealEditExists() throws { let suiteName = "termind.snippet-timestamp-tests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) diff --git a/TFITests/MobileTerminalListTests.swift b/TFITests/MobileTerminalListTests.swift index fb3df5c6..10b6fd36 100644 --- a/TFITests/MobileTerminalListTests.swift +++ b/TFITests/MobileTerminalListTests.swift @@ -19,7 +19,7 @@ struct MobileTerminalListTests { hosts: app.library, commandHistory: app.commandHistory) - try await withHostedView(MobileCommandHistoryView(workspace: workspace)) { root in + try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in #expect(nativeVisibleCells(in: root.view).count == 2) let newest = try #require(app.commandHistory.entries(for: host.id).first) @@ -45,7 +45,7 @@ struct MobileTerminalListTests { hosts: app.library, commandHistory: history) - try await withHostedView(MobileCommandHistoryView(workspace: workspace)) { root in + try await withHostedView(MobileCommandHistoryView(library: app.library, workspace: workspace)) { root in #expect(history.entries(for: host.id).count == CommandHistoryModel.perHostLimit) let cells = nativeVisibleCells(in: root.view) #expect(!cells.isEmpty) diff --git a/TFM/DesignSystem/MenuSymbol.swift b/TFM/DesignSystem/MenuSymbol.swift index 787616f4..624d535c 100644 --- a/TFM/DesignSystem/MenuSymbol.swift +++ b/TFM/DesignSystem/MenuSymbol.swift @@ -69,6 +69,7 @@ nonisolated enum MenuSymbol { static let resetName = "arrow.uturn.backward" static let duplicate = "plus.square.on.square" static let copy = "doc.on.doc" + static let insert = "text.insert" static let paste = "doc.on.clipboard" static let execute = "play" static let executeOnHosts = "server.rack" @@ -124,7 +125,7 @@ nonisolated enum MenuSymbol { newChat, newHost, newGroup, newPackage, newFolder, newSnippet, saveAsSnippet, newIdentity, generateKey, generateCommand, importItem, download, export, upload, - details, pause, selectDevices, open, openFullScreen, edit, rename, resetName, duplicate, copy, paste, + details, pause, selectDevices, open, openFullScreen, edit, rename, resetName, duplicate, copy, insert, paste, execute, executeOnHosts, start, stop, refresh, showHiddenFiles, hideHiddenFiles, editPath, editPermissions, test, pin, unpin, archive, unarchive, delete, diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index 9c6d7907..5c89a6d9 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -454,10 +454,93 @@ final class SidebarListContainer: NSView { } } +@MainActor +enum TerminalSidebarMenuBuilder { + static func snippets( + snippet: Snippet?, canInsert: Bool, locale: Locale, + copy: @escaping (Snippet) -> Void, + insert: @escaping (Snippet) -> Void + ) -> [NativeMenuEntry] { + [ + NativeMenuEntry( + title: localized("Copy", locale: locale), + symbol: MenuSymbol.copy, + isEnabled: snippet != nil + ) { + if let snippet { copy(snippet) } + }, + NativeMenuEntry( + title: localized("Insert", locale: locale), + symbol: MenuSymbol.insert, + isEnabled: snippet != nil && canInsert + ) { + if let snippet { insert(snippet) } + }, + ] + } + + static func history( + entry: CommandHistoryEntry?, canInsert: Bool, canClear: Bool, locale: Locale, + copy: @escaping (CommandHistoryEntry) -> Void, + insert: @escaping (CommandHistoryEntry) -> Void, + saveAsSnippet: @escaping (CommandHistoryEntry) -> Void, + remove: @escaping (CommandHistoryEntry) -> Void, + clear: @escaping () -> Void + ) -> [NativeMenuEntry] { + var entries = [ + NativeMenuEntry( + title: localized("Copy", locale: locale), + symbol: MenuSymbol.copy, + isEnabled: entry != nil + ) { + if let entry { copy(entry) } + }, + NativeMenuEntry( + title: localized("Insert", locale: locale), + symbol: MenuSymbol.insert, + isEnabled: entry != nil && canInsert + ) { + if let entry { insert(entry) } + }, + NativeMenuEntry( + title: localized("Save as Snippet", locale: locale), + symbol: MenuSymbol.saveAsSnippet, + isEnabled: entry != nil + ) { + if let entry { saveAsSnippet(entry) } + }, + .separator, + ] + if let entry { + entries.append( + NativeMenuEntry( + title: localized("Remove this History", locale: locale), + symbol: MenuSymbol.removeHistory + ) { + remove(entry) + }) + } + entries.append( + NativeMenuEntry( + title: localized("Clear History", locale: locale), + symbol: MenuSymbol.clearHistory, + isEnabled: canClear, + action: clear + )) + return entries + } + + private static func localized( + _ key: String.LocalizationValue, locale: Locale + ) -> String { + AppLocalization.string(key, locale: locale) + } +} + // MARK: - Session Snippets -/// The global Snippet shelf. Execution always respects the Snippet's saved -/// Targets; the focused terminal is never an implicit destination. +/// The global Snippet shelf inserts into the focused terminal. The full +/// Snippets page remains the place for executing a Snippet on its saved Targets. private struct SnippetsSidebarView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -545,17 +628,14 @@ private struct SnippetsSidebarView: View { ) } - /// This shelf only runs what is already saved; snippets are made on the - /// Snippets page. With nothing under the pointer both verbs grey out rather - /// than the menu not appearing, so the shelf answers a right-click the same - /// way wherever it lands. + /// With nothing under the pointer both verbs grey out rather than the menu + /// not appearing, so the shelf answers a right-click the same way wherever + /// it lands. private var blankAreaMenu: [NativeMenuEntry] { - [ - NativeMenuEntry(title: localized("Copy"), symbol: MenuSymbol.copy, isEnabled: false), - NativeMenuEntry( - title: localized("Execute"), symbol: MenuSymbol.execute, isEnabled: false - ), - ] + TerminalSidebarMenuBuilder.snippets( + snippet: nil, canInsert: canInsert, locale: locale, + copy: copy, insert: insert + ) } private func snippetRow(_ snippet: Snippet) -> some View { @@ -563,17 +643,16 @@ private struct SnippetsSidebarView: View { snippet: snippet, isSelected: selectedSnippetID == snippet.id ) .termindSidebarRow(isSelected: selectedSnippetID == snippet.id) { - [ - NativeMenuEntry(title: localized("Copy"), symbol: MenuSymbol.copy) { - copyToPasteboard(snippet.body) - }, - NativeMenuEntry(title: localized("Execute"), symbol: MenuSymbol.execute) { - requestExecution(snippet) - }, - ] + TerminalSidebarMenuBuilder.snippets( + snippet: snippet, canInsert: canInsert, locale: locale, + copy: copy, insert: insert + ) + } + .onTapGesture(count: 2) { execute(snippet) } + .onTapGesture { + selectedSnippetID = snippet.id + insert(snippet) } - .onTapGesture(count: 2) { requestExecution(snippet) } - .onTapGesture { selectedSnippetID = snippet.id } } private var displayedSnippets: [Snippet] { @@ -603,14 +682,24 @@ private struct SnippetsSidebarView: View { } } - private func requestExecution(_ snippet: Snippet) { - if snippet.targetHostIDs.isEmpty { - window.hostChooser = .snippet( - SnippetHostChooserRequest( - snippetIDs: [snippet.id], strategy: .fillMissingTargets - )) - return - } + private var canInsert: Bool { + window.selectedWorkspace?.focusedPane.sshSession?.status.isLive == true + } + + private func copy(_ snippet: Snippet) { + copyToPasteboard(snippet.body) + } + + private func insert(_ snippet: Snippet) { + guard let workspace = window.selectedWorkspace, + let sshSession = workspace.focusedPane.sshSession, + sshSession.status.isLive + else { return } + workspace.deliver(snippet.body, from: sshSession) + } + + private func execute(_ snippet: Snippet) { + guard canInsert else { return } if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation { destructiveSnippet = snippet } else { @@ -619,7 +708,14 @@ private struct SnippetsSidebarView: View { } private func executeNow(_ snippet: Snippet) { - app.execute([snippet], in: window) + guard let workspace = window.selectedWorkspace, + let sshSession = workspace.focusedPane.sshSession, + sshSession.status.isLive + else { + destructiveSnippet = nil + return + } + workspace.deliver(snippet.body, from: sshSession, submit: true) destructiveSnippet = nil } } @@ -707,6 +803,7 @@ private struct HistorySidebarView: View { private struct SSHCommandHistorySidebar: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window + @Environment(\.locale) private var locale @State private var query = "" @State private var destructiveEntry: CommandHistoryEntry? @@ -816,22 +913,7 @@ private struct SSHCommandHistorySidebar: View { /// needs a row greyed out. Clear History is not one of those — it is about /// the list, so it stays live where there is nothing under the pointer. private var globalMenu: [NativeMenuEntry] { - [ - NativeMenuEntry( - title: String(localized: "Copy"), symbol: MenuSymbol.copy, isEnabled: false - ), - NativeMenuEntry( - title: String(localized: "Save as Snippet"), - symbol: MenuSymbol.saveAsSnippet, - isEnabled: false - ), - .separator, - NativeMenuEntry( - title: String(localized: "Clear History"), - symbol: MenuSymbol.clearHistory, - isEnabled: !allEntries.isEmpty, - ) { confirmsClear = true }, - ] + historyMenu(for: nil) } @ViewBuilder @@ -847,10 +929,6 @@ private struct SSHCommandHistorySidebar: View { .frame(height: 18) .padding(.horizontal, Metrics.Space.s) .padding(.vertical, 4) - .background( - Color.primary.opacity(0.055), - in: .rect(cornerRadius: Metrics.Radius.small) - ) .overlay { RoundedRectangle(cornerRadius: Metrics.Radius.small) .strokeBorder(Color.accentColor.opacity(0.55)) @@ -862,6 +940,7 @@ private struct SSHCommandHistorySidebar: View { } .padding(.vertical, 2) .padding(.horizontal, Metrics.Space.m) + .termindSidebarRowBackground(isSelected: false) } else { CommandHistoryCard( entry: entry, @@ -877,32 +956,25 @@ private struct SSHCommandHistorySidebar: View { insert(entry) } .termindSidebarRow(isSelected: selectedID == entry.id) { - [ - NativeMenuEntry( - title: String(localized: "Copy"), symbol: MenuSymbol.copy - ) { - copyToPasteboard(entry.command) - }, - NativeMenuEntry( - title: String(localized: "Save as Snippet"), - symbol: MenuSymbol.saveAsSnippet - ) { - beginSnippetNaming(entry) - }, - .separator, - NativeMenuEntry( - title: String(localized: "Remove this History"), - symbol: MenuSymbol.removeHistory, - ) { delete(entry) }, - NativeMenuEntry( - title: String(localized: "Clear History"), - symbol: MenuSymbol.clearHistory, - ) { confirmsClear = true }, - ] + historyMenu(for: entry) } } } + private func historyMenu(for entry: CommandHistoryEntry?) -> [NativeMenuEntry] { + TerminalSidebarMenuBuilder.history( + entry: entry, + canInsert: canInsert, + canClear: !allEntries.isEmpty, + locale: locale, + copy: { copyToPasteboard($0.command) }, + insert: insert, + saveAsSnippet: beginSnippetNaming, + remove: delete, + clear: { confirmsClear = true } + ) + } + private var historyListItems: [SidebarListItem] { let entries = allEntries.map { SidebarListItem.history($0, isNaming: namingEntryID == $0.id) @@ -924,13 +996,15 @@ private struct SSHCommandHistorySidebar: View { } private func insert(_ entry: CommandHistoryEntry) { - guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { - return - } + guard let workspace = window.selectedWorkspace, + let sshSession = workspace.focusedPane.sshSession, + sshSession.status.isLive + else { return } workspace.deliver(entry.command, from: sshSession) } private func execute(_ entry: CommandHistoryEntry) { + guard canInsert else { return } if CommandRiskClassifier.assess(entry.command).requiresSecondConfirmation { destructiveEntry = entry } else { @@ -939,7 +1013,11 @@ private struct SSHCommandHistorySidebar: View { } private func executeNow(_ entry: CommandHistoryEntry) { - guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { + guard let workspace = window.selectedWorkspace, + let sshSession = workspace.focusedPane.sshSession, + sshSession.status.isLive + else { + destructiveEntry = nil return } workspace.deliver(entry.command, from: sshSession, submit: true) @@ -968,6 +1046,10 @@ private struct SSHCommandHistorySidebar: View { namingEntryID = entry.id } + private var canInsert: Bool { + sshSession?.status.isLive == true + } + private func cancelSnippetNaming() { namingEntryID = nil snippetName = "" @@ -1336,14 +1418,14 @@ private struct SidebarInlineTextField: NSViewRepresentable { field.target = context.coordinator field.action = #selector(Coordinator.submit) field.stringValue = text - field.placeholderString = prompt field.isBezeled = false + field.isBordered = false field.drawsBackground = false field.focusRingType = .none - field.font = NSFont.preferredFont(forTextStyle: .body) field.lineBreakMode = .byTruncatingTail field.cell?.usesSingleLineMode = true field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + configure(field) context.coordinator.requestFocus(for: field) return field } @@ -1352,6 +1434,19 @@ private struct SidebarInlineTextField: NSViewRepresentable { if field.stringValue != text { field.stringValue = text } + configure(field) + } + + private func configure(_ field: NSTextField) { + let font = NSFont.preferredFont(forTextStyle: .body) + field.font = font + field.textColor = .labelColor + field.placeholderAttributedString = NSAttributedString( + string: prompt, + attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor] + ) + field.currentEditor()?.textColor = .labelColor + field.currentEditor()?.drawsBackground = false } final class Coordinator: NSObject, NSTextFieldDelegate { @@ -1375,6 +1470,8 @@ private struct SidebarInlineTextField: NSViewRepresentable { didRequestFocus = true DispatchQueue.main.async { field.window?.makeFirstResponder(field) + field.currentEditor()?.textColor = .labelColor + field.currentEditor()?.drawsBackground = false } } diff --git a/TFMTests/BroadcastInputTests.swift b/TFMTests/BroadcastInputTests.swift index 8468a049..b4adbe2a 100644 --- a/TFMTests/BroadcastInputTests.swift +++ b/TFMTests/BroadcastInputTests.swift @@ -100,6 +100,23 @@ struct BroadcastInputTests { #expect(secondWrites == [input]) } + @Test + func programmaticInsertAndSubmitUseTheSameBroadcastPath() { + let (workspace, firstSSHSession, secondSSHSession) = makeTwoSSHSessionWorkspace() + var firstWrites: [Data] = [] + var secondWrites: [Data] = [] + firstSSHSession.inputSink = { firstWrites.append($0) } + secondSSHSession.inputSink = { secondWrites.append($0) } + workspace.broadcastsInput = true + + workspace.deliver("uptime", from: firstSSHSession) + workspace.deliver("whoami", from: firstSSHSession, submit: true) + + let expected = [Data("uptime".utf8), Data("whoami\r".utf8)] + #expect(firstWrites == expected) + #expect(secondWrites == expected) + } + @Test func nonInteractiveTerminalWritesNeverBroadcast() { let (workspace, firstSSHSession, secondSSHSession) = makeTwoSSHSessionWorkspace() diff --git a/TFMTests/ListViewMigrationTests.swift b/TFMTests/ListViewMigrationTests.swift index 2854de87..febf4e91 100644 --- a/TFMTests/ListViewMigrationTests.swift +++ b/TFMTests/ListViewMigrationTests.swift @@ -300,6 +300,56 @@ struct ListViewMigrationTests { ] ) } + + @Test @MainActor + func terminalSidebarMenusKeepCopyInsertAndHistoryGroupingAligned() { + let locale = Locale(identifier: "en") + let snippet = Snippet(title: "Status", body: "uptime") + var insertedSnippet: Snippet.ID? + let snippetEntries = TerminalSidebarMenuBuilder.snippets( + snippet: snippet, canInsert: true, locale: locale, + copy: { _ in }, insert: { insertedSnippet = $0.id } + ) + + #expect(snippetEntries.map(\.title) == ["Copy", "Insert"]) + #expect(snippetEntries.allSatisfy { $0.isEnabled }) + snippetEntries[1].action() + #expect(insertedSnippet == snippet.id) + + let disabledSnippetEntries = TerminalSidebarMenuBuilder.snippets( + snippet: nil, canInsert: false, locale: locale, + copy: { _ in }, insert: { _ in } + ) + #expect(disabledSnippetEntries.map(\.title) == ["Copy", "Insert"]) + #expect(disabledSnippetEntries.allSatisfy { !$0.isEnabled }) + + let history = CommandHistoryEntry(hostID: UUID(), command: "whoami") + let historyEntries = TerminalSidebarMenuBuilder.history( + entry: history, canInsert: true, canClear: true, locale: locale, + copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }, + remove: { _ in }, clear: {} + ) + #expect( + historyEntries.map(\.title) + == [ + "Copy", "Insert", "Save as Snippet", "", "Remove this History", + "Clear History", + ] + ) + #expect(historyEntries[3].isSeparator) + + let blankHistoryEntries = TerminalSidebarMenuBuilder.history( + entry: nil, canInsert: false, canClear: true, locale: locale, + copy: { _ in }, insert: { _ in }, saveAsSnippet: { _ in }, + remove: { _ in }, clear: {} + ) + #expect( + blankHistoryEntries.map(\.title) + == ["Copy", "Insert", "Save as Snippet", "", "Clear History"] + ) + #expect(blankHistoryEntries.prefix(3).allSatisfy { !$0.isEnabled }) + #expect(blankHistoryEntries.last?.isEnabled == true) + } } @Test