Refine Snippets & History UI
This commit is contained in:
1 parent
cf87058672
commit
4cb443ccfc
9 files changed
+368
-132
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user