507 lines
17 KiB
C++
507 lines
17 KiB
C++
#include "WorkspacePanel.h"
|
||
|
||
#include <QAbstractItemView>
|
||
#include <QFrame>
|
||
#include <QGuiApplication>
|
||
#include <QHBoxLayout>
|
||
#include <QLabel>
|
||
#include <QListWidget>
|
||
#include <QListWidgetItem>
|
||
#include <QMouseEvent>
|
||
#include <QPushButton>
|
||
#include <QScreen>
|
||
#include <QSplitter>
|
||
#include <QTextEdit>
|
||
#include <QVBoxLayout>
|
||
#include <QtGlobal>
|
||
|
||
#include <limits>
|
||
#include <utility>
|
||
|
||
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<qint64>(intersection.width()) * static_cast<qint64>(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("<br>"));
|
||
}
|
||
|
||
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<QPoint> 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<qint64>::max();
|
||
qint64 bestDistance = std::numeric_limits<qint64>::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("<div style=\"color:#93a4bd;\">请选择左侧结果。</div>"));
|
||
return;
|
||
}
|
||
|
||
const WorkspaceFileInfo &file = m_snapshot.lastResults.at(index);
|
||
const QString html = QStringLiteral(
|
||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||
"<div style=\"color:#cbd5e1;\">类型:%2<br>大小:%3 字节<br>修改时间:%4</div>")
|
||
.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("<div style=\"color:#93a4bd;\">暂无文本搜索命中。</div>"));
|
||
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("<div style=\"margin-bottom:9px;\"><b style=\"color:#bfdbfe;\">%1:%2</b><br><span style=\"color:#dbe4f0;\">%3</span></div>")
|
||
.arg(htmlLine(match.relativePath))
|
||
.arg(match.lineNumber)
|
||
.arg(htmlLine(match.lineText));
|
||
}
|
||
if (m_snapshot.lastTextMatches.size() > matchCount)
|
||
{
|
||
html += QStringLiteral("<div style=\"color:#93a4bd;\">仅显示前 %1 条文本命中。</div>").arg(matchCount);
|
||
}
|
||
m_textMatchesEdit->setHtml(html);
|
||
}
|
||
|
||
void WorkspacePanel::updateRecentRead()
|
||
{
|
||
if (!m_snapshot.hasLastReadFile)
|
||
{
|
||
m_recentReadEdit->setHtml(QStringLiteral("<div style=\"color:#93a4bd;\">暂无最近读取文件。</div>"));
|
||
return;
|
||
}
|
||
|
||
const QString html = QStringLiteral(
|
||
"<div style=\"font-weight:700; color:#f8fafc; margin-bottom:8px;\">%1</div>"
|
||
"<pre style=\"white-space:pre-wrap; color:#dbe4f0; font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px; margin:0;\">%2</pre>")
|
||
.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;
|
||
}
|