mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
601 lines
16 KiB
Go
601 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch key.String() {
|
|
case "f1":
|
|
m.openModal(modalHelp)
|
|
return m, nil
|
|
case "ctrl+c", "ctrl+q":
|
|
// Nothing to lose on the start screen; quit without confirmation.
|
|
if m.snapshot.SetupMode {
|
|
m.quitting = true
|
|
return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit)
|
|
}
|
|
m.modalChoice = 1
|
|
m.openModal(modalQuit)
|
|
return m, nil
|
|
case "ctrl+o":
|
|
return m, send(m.client, "viewer.open", map[string]any{})
|
|
case "tab":
|
|
m.cycleFocus(1)
|
|
return m, nil
|
|
case "shift+tab":
|
|
m.cycleFocus(-1)
|
|
return m, nil
|
|
case "esc":
|
|
if !m.snapshot.SetupMode && m.selectedAgentCanStop() {
|
|
m.modalChoice = 1
|
|
m.openModal(modalStop)
|
|
}
|
|
return m, nil
|
|
case "up", "down":
|
|
if m.focus == focusAgents && len(m.snapshot.Agents) > 0 {
|
|
delta := 1
|
|
if key.String() == "up" {
|
|
delta = -1
|
|
}
|
|
entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
|
|
row := selectedAgentRow(entries, m.selectedAgent)
|
|
row = max(0, min(len(entries)-1, row+delta))
|
|
m.selectedAgent = entries[row].index
|
|
m.ensureAgentVisible()
|
|
m.refreshViewport()
|
|
return m, nil
|
|
}
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
delta := 1
|
|
if key.String() == "up" {
|
|
delta = -1
|
|
}
|
|
m.moveVulnerabilitySelection(delta)
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
case "enter", " ":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
if key.String() == "enter" {
|
|
m.openModal(modalVulnerability)
|
|
return m, nil
|
|
}
|
|
}
|
|
if m.focus == focusAgents {
|
|
if m.selectedAgent < len(m.snapshot.Agents) {
|
|
agentID := m.snapshot.Agents[m.selectedAgent].ID
|
|
if hasAgentChildren(agentID, m.snapshot.Agents) {
|
|
if m.collapsedAgents == nil {
|
|
m.collapsedAgents = map[string]bool{}
|
|
}
|
|
m.collapsedAgents[agentID] = !m.collapsedAgents[agentID]
|
|
m.ensureAgentVisible()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
if key.String() == "enter" && m.focus == focusInput {
|
|
value := strings.TrimSpace(m.input.Value())
|
|
m.input.SetValue("")
|
|
m.resizeViewport()
|
|
if value != "" {
|
|
return m.submit(value)
|
|
}
|
|
return m, nil
|
|
}
|
|
case "pgup":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.moveVulnerabilitySelection(-m.vulnerabilityPageItems())
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.HalfViewUp()
|
|
return m, nil
|
|
case "pgdown":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.moveVulnerabilitySelection(m.vulnerabilityPageItems())
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.viewport.HalfViewDown()
|
|
return m, nil
|
|
case "home":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.selectedVuln = 0
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
case "end":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.selectedVuln = len(m.snapshot.Vulnerabilities) - 1
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.viewport.GotoBottom()
|
|
m.followOutput = true
|
|
return m, nil
|
|
}
|
|
if m.focus == focusChat {
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(key)
|
|
return m, cmd
|
|
}
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(key)
|
|
// Typing changes how far the composer wraps, so refit it.
|
|
m.resizeViewport()
|
|
return m, cmd
|
|
}
|
|
|
|
// updateMouse routes wheel and click events to the pane under the pointer.
|
|
func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal != modalNone {
|
|
return m.updateModalMouse(msg)
|
|
}
|
|
if m.snapshot.SetupMode {
|
|
return m.updateSetupMouse(msg)
|
|
}
|
|
showSidebar, _, chatWidth, chatHeight := m.layout()
|
|
viewerHeight := m.viewerHeight()
|
|
_, vulnHeight, agentHeight := m.sidebarHeights()
|
|
x, y := msg.X, msg.Y
|
|
if m.updateMainScrollbarMouse(
|
|
msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight,
|
|
) {
|
|
return m, nil
|
|
}
|
|
if m.selection.dragging {
|
|
switch msg.Action {
|
|
case tea.MouseActionMotion:
|
|
// Clamp to the owning pane so dragging past an edge keeps
|
|
// extending the selection.
|
|
if m.selection.region == regionInput {
|
|
top := m.inputTop()
|
|
cx := min(max(x, 2+inputPromptWidth), max(2+inputPromptWidth, chatWidth-2))
|
|
cy := min(max(y, top+1), top+m.input.Height())
|
|
if line, col, ok := m.inputContentCell(cx, cy); ok {
|
|
m.extendSelection(line, col)
|
|
}
|
|
return m, nil
|
|
}
|
|
traceHeight := chatHeight - 2
|
|
cx := min(max(x, 1), max(1, chatWidth-2))
|
|
cy := min(max(y, 1), max(1, traceHeight))
|
|
if line, col, ok := m.chatContentCell(cx, cy); ok {
|
|
m.extendSelection(line, col)
|
|
}
|
|
return m, nil
|
|
case tea.MouseActionRelease:
|
|
return m, m.finishSelection()
|
|
}
|
|
}
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
if showSidebar && x >= chatWidth+1 {
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, nil
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
m.agentOffset = max(0, m.agentOffset-3)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
m.vulnOffset = max(0, m.vulnOffset-3)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.LineUp(3)
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
if showSidebar && x >= chatWidth+1 {
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, nil
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
rows := m.agentPageSize()
|
|
m.agentOffset = min(max(0, len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))-rows), m.agentOffset+3)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
return m, nil
|
|
}
|
|
m.viewport.LineDown(3)
|
|
if m.viewport.AtBottom() {
|
|
m.followOutput = true
|
|
}
|
|
return m, nil
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return m, nil
|
|
}
|
|
statusH := 0
|
|
if m.statusVisible() {
|
|
statusH = 1
|
|
}
|
|
inputTop := chatHeight + statusH
|
|
// Chat column: chat box on top, input box below the (optional) status row.
|
|
if x < chatWidth {
|
|
switch {
|
|
case y >= inputTop:
|
|
m.focus = focusInput
|
|
m.input.Focus()
|
|
if line, col, ok := m.inputContentCell(x, y); ok {
|
|
m.beginSelection(regionInput, line, col)
|
|
} else {
|
|
m.selection.active = false
|
|
}
|
|
case y < chatHeight:
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
if line, col, ok := m.chatContentCell(x, y); ok {
|
|
m.beginSelection(regionChat, line, col)
|
|
} else {
|
|
m.selection.active = false
|
|
}
|
|
default:
|
|
m.selection.active = false
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if !showSidebar || x < chatWidth+1 {
|
|
return m, nil
|
|
}
|
|
// Sidebar: viewer, agents, vulnerabilities, then stats.
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, send(m.client, "viewer.open", map[string]any{})
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
// Content starts after the top border (1) and vertical padding (1).
|
|
entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
|
|
start := windowStart(m.agentOffset, len(entries), max(1, agentHeight-4))
|
|
localY := y - viewerHeight
|
|
if row := start + localY - 2; localY >= 2 && localY < agentHeight-2 && row < len(entries) {
|
|
m.selectedAgent = entries[row].index
|
|
agentID := m.snapshot.Agents[m.selectedAgent].ID
|
|
if hasAgentChildren(agentID, m.snapshot.Agents) {
|
|
m.collapsedAgents[agentID] = !m.collapsedAgents[agentID]
|
|
m.ensureAgentVisible()
|
|
}
|
|
m.refreshViewport()
|
|
}
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
// Content starts after the top border (1); clicking a row opens its detail.
|
|
row := y - viewerHeight - agentHeight - 1
|
|
if idx := m.vulnerabilityIndexAtRow(row); row >= 0 && row < vulnHeight-2 && idx >= 0 {
|
|
m.selectedVuln = idx
|
|
m.openModal(modalVulnerability)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) updateMainScrollbarMouse(
|
|
msg tea.MouseMsg,
|
|
showSidebar bool,
|
|
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
|
|
) bool {
|
|
if msg.Action == tea.MouseActionRelease {
|
|
if m.draggingScrollbar == scrollbarNone {
|
|
return false
|
|
}
|
|
m.draggingScrollbar = scrollbarNone
|
|
return true
|
|
}
|
|
if msg.Action == tea.MouseActionMotion && m.draggingScrollbar != scrollbarNone {
|
|
m.scrollFromMouse(m.draggingScrollbar, msg.Y, chatHeight, viewerHeight, agentHeight)
|
|
return true
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return false
|
|
}
|
|
|
|
target := scrollbarNone
|
|
switch {
|
|
case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
|
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
|
target = scrollbarTrace
|
|
case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 &&
|
|
msg.Y < viewerHeight+agentHeight-2 &&
|
|
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
|
target = scrollbarAgents
|
|
case showSidebar && vulnHeight > 0 && msg.X == m.width-3 &&
|
|
msg.Y >= viewerHeight+agentHeight+1 &&
|
|
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
|
totalRows, _ := m.vulnerabilityScrollRows()
|
|
if totalRows > m.vulnerabilityPageSize() {
|
|
target = scrollbarFindings
|
|
}
|
|
}
|
|
if target == scrollbarNone {
|
|
return false
|
|
}
|
|
m.draggingScrollbar = target
|
|
m.scrollFromMouse(target, msg.Y, chatHeight, viewerHeight, agentHeight)
|
|
return true
|
|
}
|
|
|
|
func (m *Model) scrollFromMouse(
|
|
target scrollbarTarget,
|
|
y, chatHeight, viewerHeight, agentHeight int,
|
|
) {
|
|
switch target {
|
|
case scrollbarTrace:
|
|
height := max(1, chatHeight-2)
|
|
offset := scrollbarOffset(y-1, height, m.viewport.TotalLineCount(), m.viewport.VisibleLineCount())
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.viewport.SetYOffset(offset)
|
|
m.followOutput = m.viewport.AtBottom()
|
|
case scrollbarAgents:
|
|
height := m.agentPageSize()
|
|
total := len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
m.agentOffset = scrollbarOffset(y-viewerHeight-2, height, total, height)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case scrollbarFindings:
|
|
height := m.vulnerabilityPageSize()
|
|
totalRows, _ := m.vulnerabilityScrollRows()
|
|
rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
}
|
|
|
|
func scrollbarOffset(row, height, total, visible int) int {
|
|
maxOffset := max(0, total-visible)
|
|
if height <= 1 || maxOffset == 0 {
|
|
return 0
|
|
}
|
|
return maxOffset * min(max(0, row), height-1) / (height - 1)
|
|
}
|
|
|
|
func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.LineUp(3)
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
m.viewport.LineDown(3)
|
|
if m.viewport.AtBottom() {
|
|
m.followOutput = true
|
|
}
|
|
return m, nil
|
|
}
|
|
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
|
m.focus = focusInput
|
|
m.input.Focus()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal == modalVulnerability {
|
|
view := m.modalView()
|
|
left, top, _, _ := m.centeredViewBounds(view)
|
|
viewportLeft := left + 4 // border and three-cell dialog padding
|
|
viewportTop := top + 3 // border and two-cell dialog padding
|
|
insideViewport := msg.X >= viewportLeft && msg.X < viewportLeft+m.vulnViewport.Width+2 &&
|
|
msg.Y >= viewportTop && msg.Y < viewportTop+m.vulnViewport.Height
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
if insideViewport {
|
|
m.vulnViewport.LineUp(3)
|
|
}
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
if insideViewport {
|
|
m.vulnViewport.LineDown(3)
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return m, nil
|
|
}
|
|
view := m.modalView()
|
|
switch m.modal {
|
|
case modalQuit, modalStop:
|
|
if m.centeredLabelHit(view, "Yes", msg.X, msg.Y) {
|
|
m.modalChoice = 0
|
|
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
|
}
|
|
if m.centeredLabelHit(view, "No", msg.X, msg.Y) {
|
|
m.modalChoice = 1
|
|
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
|
}
|
|
case modalVulnerability:
|
|
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
|
|
m.modalChoice = 0
|
|
cmd := m.startVulnerabilityCopy()
|
|
return m, cmd
|
|
}
|
|
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
|
|
m.modalChoice = 1
|
|
m.closeModal()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) centeredViewBounds(view string) (left, top, width, height int) {
|
|
width = lipgloss.Width(view)
|
|
height = strings.Count(view, "\n") + 1
|
|
left = max(0, (m.width-width)/2)
|
|
top = max(0, (m.height-height)/2)
|
|
return
|
|
}
|
|
|
|
func (m Model) centeredLabelHit(view, label string, x, y int) bool {
|
|
left, top, _, _ := m.centeredViewBounds(view)
|
|
for row, line := range strings.Split(view, "\n") {
|
|
plain := ansi.Strip(line)
|
|
index := strings.Index(plain, label)
|
|
if index < 0 || y != top+row {
|
|
continue
|
|
}
|
|
start := left + ansi.StringWidth(plain[:index])
|
|
return x >= start-1 && x < start+ansi.StringWidth(label)+1
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Model) cycleFocus(delta int) {
|
|
available := []focusMode{focusInput, focusChat}
|
|
if m.width >= 120 {
|
|
available = append(available, focusAgents)
|
|
if len(m.snapshot.Vulnerabilities) > 0 {
|
|
available = append(available, focusVulnerabilities)
|
|
}
|
|
}
|
|
idx := 0
|
|
for i, focus := range available {
|
|
if focus == m.focus {
|
|
idx = i
|
|
}
|
|
}
|
|
m.focus = available[clampCycle(idx+delta, len(available))]
|
|
if m.focus == focusInput {
|
|
m.input.Focus()
|
|
} else {
|
|
m.input.Blur()
|
|
}
|
|
}
|
|
|
|
func clampCycle(value, length int) int {
|
|
if length <= 0 {
|
|
return 0
|
|
}
|
|
return (value%length + length) % length
|
|
}
|
|
|
|
func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal == modalHelp {
|
|
if key.String() != "" {
|
|
m.closeModal()
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.modal == modalVulnerability {
|
|
switch key.String() {
|
|
case "esc":
|
|
m.closeModal()
|
|
case "left", "right", "tab", "shift+tab":
|
|
m.modalChoice = 1 - m.modalChoice
|
|
case "enter":
|
|
if m.modalChoice == 0 {
|
|
cmd := m.startVulnerabilityCopy()
|
|
return m, cmd
|
|
}
|
|
m.closeModal()
|
|
case "c":
|
|
m.modalChoice = 0
|
|
cmd := m.startVulnerabilityCopy()
|
|
return m, cmd
|
|
case "up":
|
|
m.vulnViewport.LineUp(1)
|
|
case "down":
|
|
m.vulnViewport.LineDown(1)
|
|
case "pgup":
|
|
m.vulnViewport.HalfViewUp()
|
|
case "pgdown":
|
|
m.vulnViewport.HalfViewDown()
|
|
case "home":
|
|
m.vulnViewport.GotoTop()
|
|
case "end":
|
|
m.vulnViewport.GotoBottom()
|
|
}
|
|
return m, nil
|
|
}
|
|
switch key.String() {
|
|
case "esc":
|
|
if m.modal == modalConfirmMount {
|
|
// The backend is waiting on an answer; escape declines it.
|
|
return m, m.answerMountConfirmation(false)
|
|
}
|
|
m.closeModal()
|
|
return m, nil
|
|
case "left", "right", "up", "down", "tab":
|
|
m.modalChoice = 1 - m.modalChoice
|
|
return m, nil
|
|
case "enter":
|
|
modal, choice := m.modal, m.modalChoice
|
|
if modal == modalConfirmMount {
|
|
// The snapshot closes this prompt once the backend has the answer.
|
|
return m, m.answerMountConfirmation(choice == 0)
|
|
}
|
|
m.closeModal()
|
|
if choice == 1 {
|
|
return m, nil
|
|
}
|
|
if modal == modalQuit {
|
|
m.quitting = true
|
|
return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit)
|
|
}
|
|
if modal == modalStop && m.selectedAgentCanStop() {
|
|
agent := m.snapshot.Agents[m.selectedAgent]
|
|
return m, send(m.client, "agent.stop", map[string]any{"agent_id": agent.ID})
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) openModal(mode modalMode) {
|
|
m.modal = mode
|
|
m.input.Blur()
|
|
if mode == modalConfirmMount {
|
|
// A consent prompt defaults to declining.
|
|
m.modalChoice = 1
|
|
}
|
|
if mode == modalVulnerability {
|
|
m.modalChoice = 1
|
|
m.vulnerabilityCopied = false
|
|
m.vulnerabilityCopyError = ""
|
|
m.resizeVulnerabilityViewport()
|
|
m.vulnViewport.GotoTop()
|
|
}
|
|
}
|
|
|
|
func (m *Model) closeModal() {
|
|
m.modal = modalNone
|
|
if m.focus == focusInput {
|
|
m.input.Focus()
|
|
}
|
|
}
|