diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index 13b4f4d..777bbcd 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -20,6 +20,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/AttachedFilesPlace.qml qml/controls/BottomBar.qml + qml/controls/FileMentionPopup.qml qml/controls/FileEditsActionBar.qml qml/controls/ContextViewer.qml qml/controls/Toast.qml @@ -68,6 +69,7 @@ qt_add_qml_module(QodeAssistChatView FileItem.hpp FileItem.cpp ChatFileManager.hpp ChatFileManager.cpp ChatCompressor.hpp ChatCompressor.cpp + FileMentionItem.hpp FileMentionItem.cpp ) target_link_libraries(QodeAssistChatView diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 1203ed1..0e064e1 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Petr Mironychev + * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * @@ -21,9 +21,12 @@ #include #include +#include +#include #include #include #include +#include #include #include @@ -225,6 +228,18 @@ ChatRootView::ChatRootView(QQuickItem *parent) this, &ChatRootView::refreshRules); + connect( + ProjectExplorer::ProjectManager::instance(), + &ProjectExplorer::ProjectManager::projectAdded, + this, + &ChatRootView::openFilesChanged); + + connect( + ProjectExplorer::ProjectManager::instance(), + &ProjectExplorer::ProjectManager::projectRemoved, + this, + &ChatRootView::openFilesChanged); + connect( &Settings::chatAssistantSettings().enableChatTools, &Utils::BaseAspect::changed, @@ -738,6 +753,13 @@ void ChatRootView::openSettings() Core::ICore::showOptionsDialog(Constants::QODE_ASSIST_CHAT_ASSISTANT_SETTINGS_PAGE_ID); } +void ChatRootView::openFileInEditor(const QString &filePath) +{ + if (filePath.isEmpty()) + return; + Core::EditorManager::openEditor(Utils::FilePath::fromString(filePath)); +} + void ChatRootView::updateInputTokensCount() { int inputTokens = m_messageTokensCount; @@ -788,6 +810,8 @@ void ChatRootView::onEditorAboutToClose(Core::IEditor *editor) if (editor) { m_currentEditors.removeOne(editor); } + + emit openFilesChanged(); } void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor) @@ -805,6 +829,7 @@ void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath { if (editor && editor->document()) { m_currentEditors.append(editor); + emit openFilesChanged(); } } diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 1eb290d..6fbc12c 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Petr Mironychev + * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * @@ -20,6 +20,7 @@ #pragma once #include +#include #include "ChatFileManager.hpp" #include "ChatModel.hpp" @@ -104,6 +105,8 @@ public: Q_INVOKABLE void openRulesFolder(); Q_INVOKABLE void openSettings(); + Q_INVOKABLE void openFileInEditor(const QString &filePath); + Q_INVOKABLE void updateInputTokensCount(); int inputTokensCount() const; @@ -222,6 +225,8 @@ signals: void compressionCompleted(const QString &compressedChatPath); void compressionFailed(const QString &error); + void openFilesChanged(); + private: void updateFileEditStatus(const QString &editId, const QString &status); QString getChatsHistoryDir() const; diff --git a/ChatView/FileMentionItem.cpp b/ChatView/FileMentionItem.cpp new file mode 100644 index 0000000..516e5cf --- /dev/null +++ b/ChatView/FileMentionItem.cpp @@ -0,0 +1,401 @@ +/* + * Copyright (C) 2026 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "FileMentionItem.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace QodeAssist::Chat { + +FileMentionItem::FileMentionItem(QQuickItem *parent) + : QQuickItem(parent) +{} + +QVariantList FileMentionItem::searchResults() const +{ + return m_searchResults; +} + +int FileMentionItem::currentIndex() const +{ + return m_currentIndex; +} + +void FileMentionItem::setCurrentIndex(int index) +{ + if (m_currentIndex == index) + return; + m_currentIndex = index; + emit currentIndexChanged(); +} + +void FileMentionItem::updateSearch(const QString &query) +{ + m_lastQuery = query; + + QVariantList openFiles = getOpenFiles(query); + QVariantList projectResults = searchProjectFiles(query); + + QSet openPaths; + for (const QVariant &item : std::as_const(openFiles)) { + const QVariantMap map = item.toMap(); + openPaths.insert(map.value("absolutePath").toString()); + } + + QVariantList combined = openFiles; + for (const QVariant &item : std::as_const(projectResults)) { + const QVariantMap map = item.toMap(); + if (!map.value("isProject").toBool() + && openPaths.contains(map.value("absolutePath").toString())) + continue; + combined.append(item); + } + + m_searchResults = combined; + m_currentIndex = 0; + emit searchResultsChanged(); + emit currentIndexChanged(); +} + +void FileMentionItem::refreshSearch() +{ + if (!m_lastQuery.isNull()) + updateSearch(m_lastQuery); +} + +void FileMentionItem::moveUp() +{ + if (m_currentIndex > 0) { + m_currentIndex--; + emit currentIndexChanged(); + } +} + +void FileMentionItem::moveDown() +{ + if (m_currentIndex < m_searchResults.size() - 1) { + m_currentIndex++; + emit currentIndexChanged(); + } +} + +void FileMentionItem::selectCurrent() +{ + if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) + return; + + const QVariantMap item = m_searchResults[m_currentIndex].toMap(); + if (item.value("isProject").toBool()) { + emit projectSelected(item.value("projectName").toString()); + } else { + emit fileSelected( + item.value("absolutePath").toString(), + item.value("relativePath").toString(), + item.value("projectName").toString()); + } +} + +void FileMentionItem::dismiss() +{ + m_searchResults.clear(); + m_currentIndex = 0; + emit searchResultsChanged(); + emit currentIndexChanged(); + emit dismissed(); +} + +QVariantMap FileMentionItem::handleFileSelection( + const QString &absolutePath, + const QString &relativePath, + const QString &projectName, + const QString ¤tQuery, + bool useTools) +{ + QVariantMap result; + const QString fileName = relativePath.section('/', -1); + + QString mentionKey = fileName; + const int colonIdx = currentQuery.indexOf(':'); + if (colonIdx > 0) { + const QString projPrefix = currentQuery.left(colonIdx); + if (projPrefix.compare(projectName, Qt::CaseInsensitive) == 0) + mentionKey = projPrefix + ":" + fileName; + } + + if (useTools) { + registerMention(mentionKey, absolutePath); + result["mode"] = QStringLiteral("mention"); + result["mentionText"] = "@" + mentionKey + " "; + } else { + emit fileAttachRequested({absolutePath}); + result["mode"] = QStringLiteral("attach"); + } + + return result; +} + +void FileMentionItem::registerMention(const QString &mentionKey, const QString &absolutePath) +{ + m_atMentionMap[mentionKey] = absolutePath; +} + +void FileMentionItem::clearMentions() +{ + m_atMentionMap.clear(); +} + +QString FileMentionItem::expandMentions(const QString &text) +{ + QString result = text; + + for (auto it = m_atMentionMap.constBegin(); it != m_atMentionMap.constEnd(); ++it) { + const QString &mentionKey = it.key(); + const QString &absPath = it.value(); + const QString displayName = mentionKey.section(':', -1); + const QString escaped = QRegularExpression::escape(mentionKey); + + // @key:N-M -> hyperlink + inline code block + const QRegularExpression rangeRe("@" + escaped + ":(\\d+)-(\\d+)(?=\\s|$)"); + QRegularExpressionMatchIterator matchIt = rangeRe.globalMatch(result); + QList matches; + while (matchIt.hasNext()) + matches.append(matchIt.next()); + + for (int i = matches.size() - 1; i >= 0; --i) { + const auto &m = matches[i]; + const int startLine = m.captured(1).toInt(); + const int endLine = m.captured(2).toInt(); + const QString ext = fileExtension(absPath); + const QString snippet = readFileLines(absPath, startLine, endLine); + const QString replacement + = QString("[@%1:%2-%3](file://%4)\n```%5\n%6```") + .arg(displayName) + .arg(startLine) + .arg(endLine) + .arg(absPath, ext, snippet); + result.replace(m.capturedStart(), m.capturedLength(), replacement); + } + + // @key -> hyperlink only + const QRegularExpression simpleRe("@" + escaped + "(?=\\s|$)"); + result.replace(simpleRe, QString("[@%1](file://%2)").arg(displayName, absPath)); + } + return result; +} + +QVariantList FileMentionItem::searchProjectFiles(const QString &query) +{ + QVariantList results; + + struct FileResult + { + QString absolutePath; + QString relativePath; + QString projectName; + int priority; + }; + + const auto allProjects = ProjectExplorer::ProjectManager::projects(); + + QString projectFilter; + QString fileQuery = query; + const int colonIdx = query.indexOf(':'); + if (colonIdx > 0) { + const QString prefix = query.left(colonIdx); + for (auto project : allProjects) { + if (project && project->displayName().compare(prefix, Qt::CaseInsensitive) == 0) { + projectFilter = project->displayName(); + fileQuery = query.mid(colonIdx + 1); + break; + } + } + } + + if (projectFilter.isEmpty() && colonIdx < 0) { + const QString lowerQ = query.toLower(); + for (auto project : allProjects) { + if (!project) + continue; + const QString name = project->displayName(); + if (query.isEmpty() || name.toLower().startsWith(lowerQ)) { + QVariantMap item; + item["absolutePath"] = QString(); + item["relativePath"] = name; + item["projectName"] = name; + item["isProject"] = true; + results.append(item); + } + } + } + + QList candidates; + const QString lowerFileQuery = fileQuery.toLower(); + const bool emptyFileQuery = fileQuery.isEmpty(); + + for (auto project : allProjects) { + if (!project) + continue; + if (!projectFilter.isEmpty() && project->displayName() != projectFilter) + continue; + + const auto projectFiles = project->files(ProjectExplorer::Project::SourceFiles); + const QString projectDir = project->projectDirectory().path(); + const QString projectName = project->displayName(); + + for (const auto &filePath : projectFiles) { + const QString absolutePath = filePath.path(); + const QFileInfo fileInfo(absolutePath); + const QString fileName = fileInfo.fileName(); + const QString relativePath = QDir(projectDir).relativeFilePath(absolutePath); + const QString lowerFileName = fileName.toLower(); + const QString lowerRelativePath = relativePath.toLower(); + + int priority = -1; + if (emptyFileQuery) { + priority = 3; + } else if (lowerFileName == lowerFileQuery) { + priority = 0; + } else if (lowerFileName.startsWith(lowerFileQuery)) { + priority = 1; + } else if (lowerFileName.contains(lowerFileQuery)) { + priority = 2; + } else if (lowerRelativePath.contains(lowerFileQuery)) { + priority = 3; + } + + if (priority >= 0) + candidates.append({absolutePath, relativePath, projectName, priority}); + } + } + + std::sort(candidates.begin(), candidates.end(), [](const FileResult &a, const FileResult &b) { + if (a.priority != b.priority) + return a.priority < b.priority; + return a.relativePath < b.relativePath; + }); + + const int maxFiles = qMax(0, 10 - results.size()); + const int count = qMin(candidates.size(), maxFiles); + for (int i = 0; i < count; i++) { + QVariantMap item; + item["absolutePath"] = candidates[i].absolutePath; + item["relativePath"] = candidates[i].relativePath; + item["projectName"] = candidates[i].projectName; + item["isProject"] = false; + results.append(item); + } + + return results; +} + +QVariantList FileMentionItem::getOpenFiles(const QString &query) +{ + QVariantList results; + const QString lowerQuery = query.toLower(); + const bool emptyQuery = query.isEmpty(); + QSet addedPaths; + + auto tryAddDocument = [&](Core::IDocument *document) { + if (!document) + return; + + const QString absolutePath = document->filePath().toFSPathString(); + if (absolutePath.isEmpty() || addedPaths.contains(absolutePath)) + return; + + const QFileInfo fileInfo(absolutePath); + const QString fileName = fileInfo.fileName(); + if (fileName.isEmpty()) + return; + + QString relativePath = absolutePath; + QString projectName; + + auto project = ProjectExplorer::ProjectManager::projectForFile(document->filePath()); + if (project) { + projectName = project->displayName(); + relativePath = QDir(project->projectDirectory().path()).relativeFilePath(absolutePath); + } + + if (!emptyQuery) { + const QString lowerFileName = fileName.toLower(); + const QString lowerRelativePath = relativePath.toLower(); + if (!lowerFileName.contains(lowerQuery) && !lowerRelativePath.contains(lowerQuery)) + return; + } + + addedPaths.insert(absolutePath); + + QVariantMap item; + item["absolutePath"] = absolutePath; + item["relativePath"] = relativePath; + item["projectName"] = projectName; + item["isProject"] = false; + item["isOpen"] = true; + results.append(item); + }; + + if (auto current = Core::EditorManager::currentEditor()) + tryAddDocument(current->document()); + + for (auto editor : Core::EditorManager::visibleEditors()) + if (editor) + tryAddDocument(editor->document()); + + for (auto document : Core::DocumentModel::openedDocuments()) + tryAddDocument(document); + + return results; +} + +QString FileMentionItem::readFileLines(const QString &filePath, int startLine, int endLine) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + QTextStream stream(&file); + QString result; + int lineNum = 1; + while (!stream.atEnd()) { + const QString line = stream.readLine(); + if (lineNum >= startLine) + result += line + '\n'; + if (lineNum >= endLine) + break; + ++lineNum; + } + return result; +} + +QString FileMentionItem::fileExtension(const QString &filePath) +{ + const int dot = filePath.lastIndexOf('.'); + return dot >= 0 ? filePath.mid(dot + 1) : QString(); +} + +} // namespace QodeAssist::Chat diff --git a/ChatView/FileMentionItem.hpp b/ChatView/FileMentionItem.hpp new file mode 100644 index 0000000..c64cea4 --- /dev/null +++ b/ChatView/FileMentionItem.hpp @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +namespace QodeAssist::Chat { + +class FileMentionItem : public QQuickItem +{ + Q_OBJECT + Q_PROPERTY(QVariantList searchResults READ searchResults NOTIFY searchResultsChanged FINAL) + Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged FINAL) + + QML_ELEMENT + +public: + explicit FileMentionItem(QQuickItem *parent = nullptr); + + QVariantList searchResults() const; + int currentIndex() const; + void setCurrentIndex(int index); + + Q_INVOKABLE void updateSearch(const QString &query); + Q_INVOKABLE void refreshSearch(); + Q_INVOKABLE void moveUp(); + Q_INVOKABLE void moveDown(); + Q_INVOKABLE void selectCurrent(); + Q_INVOKABLE void dismiss(); + + Q_INVOKABLE QVariantMap handleFileSelection( + const QString &absolutePath, + const QString &relativePath, + const QString &projectName, + const QString ¤tQuery, + bool useTools); + + Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath); + Q_INVOKABLE void clearMentions(); + Q_INVOKABLE QString expandMentions(const QString &text); + +signals: + void searchResultsChanged(); + void currentIndexChanged(); + void fileSelected(const QString &absolutePath, + const QString &relativePath, + const QString &projectName); + void projectSelected(const QString &projectName); + void dismissed(); + void fileAttachRequested(const QStringList &filePaths); + +private: + QVariantList searchProjectFiles(const QString &query); + QVariantList getOpenFiles(const QString &query); + QString readFileLines(const QString &filePath, int startLine, int endLine); + static QString fileExtension(const QString &filePath); + + QVariantList m_searchResults; + int m_currentIndex = 0; + QString m_lastQuery; + QHash m_atMentionMap; +}; + +} // namespace QodeAssist::Chat diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index e7631c2..5c3e99d 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Petr Mironychev + * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * @@ -280,6 +280,10 @@ ChatRootView { messageInput.cursorPosition = model.content.length root.chatModel.resetModelTo(idx) } + + onOpenFileRequested: function(filePath) { + root.openFileInEditor(filePath) + } } } @@ -368,7 +372,38 @@ ChatRootView { } } - onTextChanged: root.calculateMessageTokensCount(messageInput.text) + onTextChanged: { + root.calculateMessageTokensCount(messageInput.text) + var cursorPos = messageInput.cursorPosition + var textBefore = messageInput.text.substring(0, cursorPos) + var atIndex = textBefore.lastIndexOf('@') + if (atIndex >= 0) { + var query = textBefore.substring(atIndex + 1) + if (query.indexOf(' ') === -1 && query.indexOf('\n') === -1) { + fileMention.updateSearch(query) + return + } + } + fileMention.dismiss() + } + + Keys.onPressed: function(event) { + if (fileMentionPopup.visible) { + if (event.key === Qt.Key_Down) { + fileMention.moveDown() + event.accepted = true + } else if (event.key === Qt.Key_Up) { + fileMention.moveUp() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + fileMention.selectCurrent() + event.accepted = true + } else if (event.key === Qt.Key_Escape) { + fileMention.dismiss() + event.accepted = true + } + } + } MouseArea { anchors.fill: parent @@ -480,7 +515,7 @@ ChatRootView { sequences: ["Ctrl+Return", "Ctrl+Enter"] context: Qt.WindowShortcut onActivated: { - if (messageInput.activeFocus && !Qt.inputMethod.visible) { + if (messageInput.activeFocus && !Qt.inputMethod.visible && !fileMentionPopup.visible) { root.sendChatMessage() } } @@ -497,8 +532,9 @@ ChatRootView { } function sendChatMessage() { - root.sendMessage(messageInput.text) + root.sendMessage(fileMention.expandMentions(messageInput.text)) messageInput.text = "" + fileMention.clearMentions() scrollToBottom() } @@ -572,6 +608,93 @@ ChatRootView { infoToast.show(root.lastInfoMessage) } } + function onOpenFilesChanged() { + if (fileMentionPopup.visible) + Qt.callLater(fileMention.refreshSearch) + } + } + + FileMentionItem { + id: fileMention + + onProjectSelected: function(projectName) { + var cursorPos = messageInput.cursorPosition + var text = messageInput.text + var textBefore = text.substring(0, cursorPos) + var atIndex = textBefore.lastIndexOf('@') + var mention = '@' + projectName + ':' + if (atIndex >= 0) { + var newText = text.substring(0, atIndex) + mention + text.substring(cursorPos) + messageInput.text = newText + messageInput.cursorPosition = atIndex + mention.length + } + fileMention.dismiss() + } + + onFileSelected: function(absolutePath, relativePath, projectName) { + var cursorPos = messageInput.cursorPosition + var text = messageInput.text + var textBefore = text.substring(0, cursorPos) + var atIndex = textBefore.lastIndexOf('@') + var currentQuery = atIndex >= 0 ? textBefore.substring(atIndex + 1) : "" + + var result = fileMention.handleFileSelection( + absolutePath, relativePath, projectName, currentQuery, root.useTools) + + if (result.mode === "mention") { + if (atIndex >= 0) { + let newText = text.substring(0, atIndex) + result.mentionText + text.substring(cursorPos) + messageInput.text = newText + messageInput.cursorPosition = atIndex + result.mentionText.length + } + } else { + if (atIndex >= 0) { + let newText = text.substring(0, atIndex) + text.substring(cursorPos) + messageInput.text = newText + messageInput.cursorPosition = atIndex + } + } + + fileMention.dismiss() + } + + onFileAttachRequested: function(filePaths) { + root.addFilesToAttachList(filePaths) + } + } + + FileMentionPopup { + id: fileMentionPopup + + z: 999 + width: Math.min(480, root.width - 20) + + x: Math.max(5, Math.min(view.x + 5, root.width - width - 5)) + y: view.y - height - 4 + + searchResults: fileMention.searchResults + + onFileSelected: function(absolutePath, relativePath, projectName) { + fileMention.fileSelected(absolutePath, relativePath, projectName) + } + onProjectSelected: function(projectName) { + fileMention.projectSelected(projectName) + } + onDismissed: fileMention.dismiss() + } + + Connections { + target: fileMention + function onCurrentIndexChanged() { + fileMentionPopup.currentIndex = fileMention.currentIndex + } + } + + Connections { + target: fileMentionPopup + function onCurrentIndexChanged() { + fileMention.currentIndex = fileMentionPopup.currentIndex + } } Component.onCompleted: { diff --git a/ChatView/qml/chatparts/ChatItem.qml b/ChatView/qml/chatparts/ChatItem.qml index e5e0393..0b20453 100644 --- a/ChatView/qml/chatparts/ChatItem.qml +++ b/ChatView/qml/chatparts/ChatItem.qml @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Petr Mironychev + * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * @@ -51,6 +51,7 @@ Rectangle { property int messageIndex: -1 signal resetChatToMessage(int index) + signal openFileRequested(string filePath) height: msgColumn.implicitHeight + 10 radius: 8 @@ -204,6 +205,15 @@ Rectangle { } } + onLinkActivated: function(link) { + if (link.startsWith("file://")) { + var filePath = link.replace(/^file:\/\//, "") + root.openFileRequested(filePath) + } else { + Qt.openUrlExternally(link) + } + } + ChatUtils { id: utils } diff --git a/ChatView/qml/chatparts/TextBlock.qml b/ChatView/qml/chatparts/TextBlock.qml index 79337dc..533f366 100644 --- a/ChatView/qml/chatparts/TextBlock.qml +++ b/ChatView/qml/chatparts/TextBlock.qml @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024-2025 Petr Mironychev + * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * @@ -29,8 +29,6 @@ TextEdit { selectionColor: palette.highlight color: palette.text - onLinkActivated: (link) => Qt.openUrlExternally(link) - MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton diff --git a/ChatView/qml/controls/FileMentionPopup.qml b/ChatView/qml/controls/FileMentionPopup.qml new file mode 100644 index 0000000..441d5af --- /dev/null +++ b/ChatView/qml/controls/FileMentionPopup.qml @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2026 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + + property var searchResults: [] + property int currentIndex: 0 + + signal fileSelected(string absolutePath, string relativePath, string projectName) + signal projectSelected(string projectName) + signal dismissed() + + visible: searchResults.length > 0 + height: Math.min(searchResults.length * 36, 36 * 6) + 2 + color: palette.window + border.color: palette.mid + border.width: 1 + radius: 4 + + ListView { + id: listView + + anchors.fill: parent + anchors.margins: 1 + model: root.searchResults + currentIndex: root.currentIndex + clip: true + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Rectangle { + id: delegateItem + + required property int index + required property var modelData + + readonly property bool isProject: modelData.isProject === true + readonly property bool isOpen: modelData.isOpen === true + readonly property string fileName: { + if (isProject) + return modelData.projectName + const parts = modelData.relativePath.split('/') + return parts[parts.length - 1] + } + + width: listView.width + height: 36 + color: index === root.currentIndex + ? palette.highlight + : (hoverArea.containsMouse + ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.25) + : "transparent") + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Item { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 3 + visible: delegateItem.isProject || delegateItem.isOpen + + color: { + if (delegateItem.index === root.currentIndex) + return Qt.rgba(palette.highlightedText.r, + palette.highlightedText.g, + palette.highlightedText.b, 0.2) + if (delegateItem.isProject) + return Qt.rgba(palette.highlight.r, + palette.highlight.g, + palette.highlight.b, 0.3) + return Qt.rgba(0.2, 0.7, 0.4, 0.3) + } + + Text { + anchors.centerIn: parent + text: delegateItem.isProject ? "P" : "O" + font.bold: true + font.pixelSize: 10 + color: { + if (delegateItem.index === root.currentIndex) + return palette.highlightedText + if (delegateItem.isProject) + return palette.highlight + return Qt.rgba(0.1, 0.6, 0.3, 1.0) + } + } + } + } + + Text { + Layout.preferredWidth: 160 + text: delegateItem.fileName + color: delegateItem.index === root.currentIndex + ? palette.highlightedText + : (delegateItem.isProject ? palette.highlight : palette.text) + font.bold: true + font.italic: delegateItem.isProject + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: delegateItem.isProject + ? "→" + : (delegateItem.modelData.projectName + " / " + delegateItem.modelData.relativePath) + color: delegateItem.index === root.currentIndex + ? (delegateItem.isProject + ? palette.highlightedText + : Qt.rgba(palette.highlightedText.r, + palette.highlightedText.g, + palette.highlightedText.b, 0.7)) + : palette.mid + font.pixelSize: delegateItem.isProject ? 12 : 11 + elide: Text.ElideLeft + horizontalAlignment: delegateItem.isProject ? Text.AlignLeft : Text.AlignRight + } + } + + MouseArea { + id: hoverArea + + anchors.fill: parent + hoverEnabled: true + onClicked: handleSelection(delegateItem.modelData) + onEntered: root.currentIndex = delegateItem.index + } + } + } + + function handleSelection(item) { + if (item.isProject === true) { + root.projectSelected(item.projectName) + } else { + root.fileSelected(item.absolutePath, item.relativePath, item.projectName) + } + } + + function selectCurrent() { + if (currentIndex >= 0 && currentIndex < searchResults.length) + handleSelection(searchResults[currentIndex]) + } + + function moveDown() { + if (currentIndex < searchResults.length - 1) + currentIndex++ + listView.positionViewAtIndex(currentIndex, ListView.Contain) + } + + function moveUp() { + if (currentIndex > 0) + currentIndex-- + listView.positionViewAtIndex(currentIndex, ListView.Contain) + } +}