#include "WorkspacePanel.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr int PanelWidth = 780; constexpr int PanelHeight = 560; constexpr int PanelGap = 16; constexpr int ScreenMargin = 8; constexpr int MaxTextMatchesInDetails = 30; int boundedCoordinate(int value, int minimum, int maximum) { if (maximum < minimum) { return minimum; } return qBound(minimum, value, maximum); } qint64 overlapArea(const QRect &first, const QRect &second) { const QRect intersection = first.intersected(second); if (intersection.isEmpty()) { return 0; } return static_cast(intersection.width()) * static_cast(intersection.height()); } qint64 squaredDistance(const QPoint &first, const QPoint &second) { const qint64 dx = first.x() - second.x(); const qint64 dy = first.y() - second.y(); return dx * dx + dy * dy; } QString compactPath(const WorkspaceFileInfo &file) { return file.relativePath + (file.isDirectory ? QStringLiteral(" [目录]") : QString()); } QString fileTypeText(const WorkspaceFileInfo &file) { if (file.isDirectory) { return QStringLiteral("目录"); } if (file.isTextFile) { return QStringLiteral("文本文件"); } return QStringLiteral("文件"); } QString htmlLine(QString text) { return text.toHtmlEscaped().replace(QStringLiteral("\n"), QStringLiteral("
")); } QFrame *card(QWidget *parent) { auto *frame = new QFrame(parent); frame->setObjectName(QStringLiteral("WorkspaceCard")); auto *layout = new QVBoxLayout(frame); layout->setContentsMargins(14, 12, 14, 12); layout->setSpacing(8); return frame; } QLabel *cardTitle(const QString &text, QWidget *parent) { auto *label = new QLabel(text, parent); label->setObjectName(QStringLiteral("CardTitle")); return label; } QTextEdit *readOnlyTextEdit(QWidget *parent) { auto *edit = new QTextEdit(parent); edit->setReadOnly(true); edit->setFrameShape(QFrame::NoFrame); edit->setLineWrapMode(QTextEdit::WidgetWidth); return edit; } } WorkspacePanel::WorkspacePanel(QWidget *parent) : QDialog(parent) , m_workspaceLabel(new QLabel(this)) , m_scanLabel(new QLabel(this)) , m_summaryLabel(new QLabel(this)) , m_resultList(new QListWidget(this)) , m_selectedDetailEdit(readOnlyTextEdit(this)) , m_textMatchesEdit(readOnlyTextEdit(this)) , m_recentReadEdit(readOnlyTextEdit(this)) , m_readButton(new QPushButton(QStringLiteral("读取"), this)) , m_openButton(new QPushButton(QStringLiteral("打开"), this)) , m_revealButton(new QPushButton(QStringLiteral("位置"), this)) , m_closeButton(new QPushButton(QStringLiteral("×"), this)) { setWindowTitle(QStringLiteral("本地工作区 Agent")); setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); setModal(false); resize(PanelWidth, PanelHeight); m_workspaceLabel->setObjectName(QStringLiteral("WorkspaceTitle")); m_workspaceLabel->setWordWrap(true); m_scanLabel->setObjectName(QStringLiteral("MetaLabel")); m_summaryLabel->setObjectName(QStringLiteral("MetaLabel")); m_summaryLabel->setWordWrap(true); m_closeButton->setObjectName(QStringLiteral("TitleCloseButton")); m_closeButton->setFixedSize(30, 30); m_resultList->setObjectName(QStringLiteral("ResultList")); m_resultList->setSelectionMode(QAbstractItemView::SingleSelection); auto *titleLayout = new QHBoxLayout; titleLayout->setContentsMargins(0, 0, 0, 0); titleLayout->setSpacing(10); titleLayout->addWidget(m_workspaceLabel, 1); titleLayout->addWidget(m_closeButton); auto *headerLayout = new QVBoxLayout; headerLayout->setSpacing(6); headerLayout->addLayout(titleLayout); headerLayout->addWidget(m_scanLabel); headerLayout->addWidget(m_summaryLabel); auto *leftCard = card(this); leftCard->setMinimumWidth(260); leftCard->layout()->addWidget(cardTitle(QStringLiteral("上一轮结果"), leftCard)); leftCard->layout()->addWidget(m_resultList); auto *selectedCard = card(this); selectedCard->layout()->addWidget(cardTitle(QStringLiteral("选中项"), selectedCard)); selectedCard->layout()->addWidget(m_selectedDetailEdit); auto *matchesCard = card(this); matchesCard->layout()->addWidget(cardTitle(QStringLiteral("文本命中"), matchesCard)); matchesCard->layout()->addWidget(m_textMatchesEdit); auto *recentReadCard = card(this); recentReadCard->layout()->addWidget(cardTitle(QStringLiteral("最近读取"), recentReadCard)); recentReadCard->layout()->addWidget(m_recentReadEdit); auto *rightLayout = new QVBoxLayout; rightLayout->setContentsMargins(0, 0, 0, 0); rightLayout->setSpacing(12); rightLayout->addWidget(selectedCard, 1); rightLayout->addWidget(matchesCard, 2); rightLayout->addWidget(recentReadCard, 2); auto *rightContainer = new QWidget(this); rightContainer->setLayout(rightLayout); auto *splitter = new QSplitter(Qt::Horizontal, this); splitter->setObjectName(QStringLiteral("WorkspaceSplitter")); splitter->addWidget(leftCard); splitter->addWidget(rightContainer); splitter->setStretchFactor(0, 0); splitter->setStretchFactor(1, 1); splitter->setSizes({280, 470}); auto *buttonLayout = new QHBoxLayout; buttonLayout->setSpacing(10); buttonLayout->addWidget(m_readButton); buttonLayout->addWidget(m_openButton); buttonLayout->addWidget(m_revealButton); buttonLayout->addStretch(1); auto *bottomCloseButton = new QPushButton(QStringLiteral("关闭"), this); buttonLayout->addWidget(bottomCloseButton); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(16, 16, 16, 16); layout->setSpacing(14); layout->addLayout(headerLayout); layout->addWidget(splitter, 1); layout->addLayout(buttonLayout); setStyleSheet(QStringLiteral( "QDialog { background: #111827; color: #e5e7eb; }" "QLabel#WorkspaceTitle { color: #f8fafc; font-size: 17px; font-weight: 700; }" "QLabel#MetaLabel { color: #93a4bd; font-size: 12px; }" "QLabel#CardTitle { color: #dbeafe; font-size: 13px; font-weight: 700; }" "QFrame#WorkspaceCard { background: #172235; border: 1px solid #2a3a55; border-radius: 8px; }" "QListWidget#ResultList { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 4px; }" "QListWidget#ResultList::item { min-height: 28px; padding: 6px 8px; border-radius: 5px; }" "QListWidget#ResultList::item:selected { background: #1d4ed8; color: #ffffff; }" "QTextEdit { background: #0f172a; color: #dbe4f0; border: 1px solid #26364f; border-radius: 6px; padding: 8px; selection-background-color: #2563eb; }" "QPushButton {" "background: #1f2d44;" "border: 1px solid #35506f;" "border-radius: 6px;" "padding: 5px 14px;" "min-height: 30px;" "color: #e5e7eb;" "}" "QPushButton:hover:enabled { background: #263b5f; }" "QPushButton:disabled { color: #64748b; background: #162033; border-color: #243247; }" "QPushButton#TitleCloseButton { min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 15px; padding: 0; font-size: 18px; }" "QPushButton#TitleCloseButton:hover:enabled { background: #7f1d1d; border-color: #991b1b; }" "QSplitter::handle { background: transparent; width: 8px; }")); connect(m_resultList, &QListWidget::currentRowChanged, this, [this]() { updateDetails(); updateActionButtons(); }); connect(m_readButton, &QPushButton::clicked, this, [this]() { const int index = selectedResultIndex(); if (index >= 0 && m_readCallback) { m_readCallback(index); } }); connect(m_openButton, &QPushButton::clicked, this, [this]() { const int index = selectedResultIndex(); if (index >= 0 && m_openCallback) { m_openCallback(index); } }); connect(m_revealButton, &QPushButton::clicked, this, [this]() { const int index = selectedResultIndex(); if (index >= 0 && m_revealCallback) { m_revealCallback(index); } }); connect(m_closeButton, &QPushButton::clicked, this, &QDialog::hide); connect(bottomCloseButton, &QPushButton::clicked, this, &QDialog::hide); updateDetails(); updateActionButtons(); } void WorkspacePanel::setSnapshot(const WorkspacePanelSnapshot &snapshot) { m_snapshot = snapshot; updateSummary(); updateResults(); updateDetails(); updateActionButtons(); } void WorkspacePanel::showNear(const QRect &avoidRect) { move(smartTopLeftNear(avoidRect)); show(); raise(); activateWindow(); } void WorkspacePanel::setReadCallback(ResultActionCallback callback) { m_readCallback = std::move(callback); } void WorkspacePanel::setOpenCallback(ResultActionCallback callback) { m_openCallback = std::move(callback); } void WorkspacePanel::setRevealCallback(ResultActionCallback callback) { m_revealCallback = std::move(callback); } void WorkspacePanel::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton && event->position().y() <= 72) { m_dragging = true; m_dragStartPosition = event->globalPosition().toPoint(); m_dragWindowPosition = pos(); event->accept(); return; } QDialog::mousePressEvent(event); } void WorkspacePanel::mouseMoveEvent(QMouseEvent *event) { if (m_dragging && (event->buttons() & Qt::LeftButton)) { move(m_dragWindowPosition + (event->globalPosition().toPoint() - m_dragStartPosition)); event->accept(); return; } QDialog::mouseMoveEvent(event); } void WorkspacePanel::mouseReleaseEvent(QMouseEvent *event) { m_dragging = false; QDialog::mouseReleaseEvent(event); } QRect WorkspacePanel::availableGeometryForRect(const QRect &rect) const { const QScreen *screen = QGuiApplication::screenAt(rect.center()); if (screen == nullptr) { screen = QGuiApplication::screenAt(rect.topLeft()); } if (screen == nullptr) { screen = QGuiApplication::primaryScreen(); } return screen == nullptr ? QRect() : screen->availableGeometry(); } QPoint WorkspacePanel::constrainedTopLeft(const QPoint &preferredTopLeft, const QRect &availableGeometry) const { if (!availableGeometry.isValid()) { return preferredTopLeft; } return QPoint( boundedCoordinate(preferredTopLeft.x(), availableGeometry.left() + ScreenMargin, availableGeometry.right() - width() - ScreenMargin), boundedCoordinate(preferredTopLeft.y(), availableGeometry.top() + ScreenMargin, availableGeometry.bottom() - height() - ScreenMargin)); } QPoint WorkspacePanel::smartTopLeftNear(const QRect &avoidRect) const { if (!avoidRect.isValid()) { return pos(); } const QRect availableGeometry = availableGeometryForRect(avoidRect); const QVector preferredTopLefts = { QPoint(avoidRect.right() + 1 + PanelGap, avoidRect.top()), QPoint(avoidRect.left() - PanelGap - width(), avoidRect.top()), QPoint(avoidRect.center().x() - width() / 2, avoidRect.top() - PanelGap - height()), QPoint(avoidRect.center().x() - width() / 2, avoidRect.bottom() + 1 + PanelGap), }; QPoint bestTopLeft = constrainedTopLeft(preferredTopLefts.first(), availableGeometry); qint64 bestOverlap = std::numeric_limits::max(); qint64 bestDistance = std::numeric_limits::max(); int bestIndex = preferredTopLefts.size(); for (int index = 0; index < preferredTopLefts.size(); ++index) { const QPoint candidateTopLeft = constrainedTopLeft(preferredTopLefts.at(index), availableGeometry); const QRect candidateRect(candidateTopLeft, size()); const qint64 candidateOverlap = overlapArea(candidateRect, avoidRect); const qint64 candidateDistance = squaredDistance(candidateTopLeft, preferredTopLefts.at(index)); if (candidateOverlap < bestOverlap || (candidateOverlap == bestOverlap && candidateDistance < bestDistance) || (candidateOverlap == bestOverlap && candidateDistance == bestDistance && index < bestIndex)) { bestTopLeft = candidateTopLeft; bestOverlap = candidateOverlap; bestDistance = candidateDistance; bestIndex = index; } } return bestTopLeft; } void WorkspacePanel::updateSummary() { const QString workspaceText = m_snapshot.workspaceRoot.trimmed().isEmpty() ? QStringLiteral("未选择工作区") : m_snapshot.workspaceRoot; const QString scanText = m_snapshot.lastScanAt.isValid() ? m_snapshot.lastScanAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("尚未扫描"); m_workspaceLabel->setText(QStringLiteral("本地工作区 Agent")); m_scanLabel->setText(QStringLiteral("工作区:%1").arg(workspaceText)); m_summaryLabel->setText(QStringLiteral("索引项:%1 最近扫描:%2") .arg(m_snapshot.indexedItemCount) .arg(scanText)); } void WorkspacePanel::updateResults() { const int currentRow = m_resultList->currentRow(); m_resultList->clear(); if (m_snapshot.lastResults.isEmpty()) { auto *item = new QListWidgetItem(QStringLiteral("暂无上一轮结果")); item->setFlags(Qt::NoItemFlags); m_resultList->addItem(item); return; } for (int index = 0; index < m_snapshot.lastResults.size(); ++index) { m_resultList->addItem(QStringLiteral("%1. %2").arg(index + 1).arg(compactPath(m_snapshot.lastResults.at(index)))); } if (currentRow >= 0 && currentRow < m_resultList->count()) { m_resultList->setCurrentRow(currentRow); } else { m_resultList->setCurrentRow(0); } } void WorkspacePanel::updateSelectedDetails() { const int index = selectedResultIndex(); if (index < 0) { m_selectedDetailEdit->setHtml(QStringLiteral("
请选择左侧结果。
")); return; } const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index); const QString html = QStringLiteral( "
%1
" "
类型:%2
大小:%3 字节
修改时间:%4
") .arg(htmlLine(compactPath(file))) .arg(fileTypeText(file)) .arg(file.sizeBytes) .arg(file.lastModified.isValid() ? file.lastModified.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) : QStringLiteral("未知")); m_selectedDetailEdit->setHtml(html); } void WorkspacePanel::updateTextMatches() { if (m_snapshot.lastTextMatches.isEmpty()) { m_textMatchesEdit->setHtml(QStringLiteral("
暂无文本搜索命中。
")); return; } QString html; const int matchCount = qMin(m_snapshot.lastTextMatches.size(), MaxTextMatchesInDetails); for (int i = 0; i < matchCount; ++i) { const WorkspaceTextMatch &match = m_snapshot.lastTextMatches.at(i); html += QStringLiteral("
%1:%2
%3
") .arg(htmlLine(match.relativePath)) .arg(match.lineNumber) .arg(htmlLine(match.lineText)); } if (m_snapshot.lastTextMatches.size() > matchCount) { html += QStringLiteral("
仅显示前 %1 条文本命中。
").arg(matchCount); } m_textMatchesEdit->setHtml(html); } void WorkspacePanel::updateRecentRead() { if (!m_snapshot.hasLastReadFile) { m_recentReadEdit->setHtml(QStringLiteral("
暂无最近读取文件。
")); return; } const QString html = QStringLiteral( "
%1
" "
%2
") .arg(htmlLine(m_snapshot.lastReadFile.relativePath)) .arg(m_snapshot.lastReadPreview.toHtmlEscaped()); m_recentReadEdit->setHtml(html); } void WorkspacePanel::updateDetails() { updateSelectedDetails(); updateTextMatches(); updateRecentRead(); } void WorkspacePanel::updateActionButtons() { const int index = selectedResultIndex(); const bool hasSelection = index >= 0; m_openButton->setEnabled(hasSelection); m_revealButton->setEnabled(hasSelection); m_readButton->setEnabled(hasSelection && !m_snapshot.lastResults.at(index).isDirectory); } int WorkspacePanel::selectedResultIndex() const { const int row = m_resultList->currentRow(); if (row < 0 || row >= m_snapshot.lastResults.size()) { return -1; } return row; }