From 6f7d8a0987646a3f66e2e462b80be16d4744816f Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:19:50 +0100 Subject: [PATCH] feat: Add drag n drop for chat (#269) feat: Add dran n drop for chat --- ChatView/CMakeLists.txt | 1 + ChatView/ChatRootView.cpp | 93 ++++++--- ChatView/ChatRootView.hpp | 3 + ChatView/qml/RootItem.qml | 12 ++ ChatView/qml/controls/SplitDropZone.qml | 239 ++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 30 deletions(-) create mode 100644 ChatView/qml/controls/SplitDropZone.qml diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index ae351ae..4f8e89a 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -24,6 +24,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/RulesViewer.qml qml/controls/Toast.qml qml/controls/TopBar.qml + qml/controls/SplitDropZone.qml RESOURCES icons/attach-file-light.svg diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 9508e36..67f1a17 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -473,20 +473,27 @@ void ChatRootView::showAttachFilesDialog() } if (dialog.exec() == QDialog::Accepted) { - QStringList newFilePaths = dialog.selectedFiles(); - if (!newFilePaths.isEmpty()) { - bool filesAdded = false; - for (const QString &filePath : std::as_const(newFilePaths)) { - if (!m_attachmentFiles.contains(filePath)) { - m_attachmentFiles.append(filePath); - filesAdded = true; - } - } - if (filesAdded) { - emit attachmentFilesChanged(); - } + addFilesToAttachList(dialog.selectedFiles()); + } +} + +void ChatRootView::addFilesToAttachList(const QStringList &filePaths) +{ + if (filePaths.isEmpty()) { + return; + } + + bool filesAdded = false; + for (const QString &filePath : filePaths) { + if (!m_attachmentFiles.contains(filePath)) { + m_attachmentFiles.append(filePath); + filesAdded = true; } } + + if (filesAdded) { + emit attachmentFilesChanged(); + } } void ChatRootView::removeFileFromAttachList(int index) @@ -507,19 +514,40 @@ void ChatRootView::showLinkFilesDialog() } if (dialog.exec() == QDialog::Accepted) { - QStringList newFilePaths = dialog.selectedFiles(); - if (!newFilePaths.isEmpty()) { - bool filesAdded = false; - for (const QString &filePath : std::as_const(newFilePaths)) { - if (!m_linkedFiles.contains(filePath)) { - m_linkedFiles.append(filePath); - filesAdded = true; - } - } - if (filesAdded) { - emit linkedFilesChanged(); - } + addFilesToLinkList(dialog.selectedFiles()); + } +} + +void ChatRootView::addFilesToLinkList(const QStringList &filePaths) +{ + if (filePaths.isEmpty()) { + return; + } + + bool filesAdded = false; + QStringList imageFiles; + + for (const QString &filePath : filePaths) { + if (isImageFile(filePath)) { + imageFiles.append(filePath); + continue; } + + if (!m_linkedFiles.contains(filePath)) { + m_linkedFiles.append(filePath); + filesAdded = true; + } + } + + if (!imageFiles.isEmpty()) { + addFilesToAttachList(imageFiles); + + m_lastInfoMessage = tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size()); + emit lastInfoMessageChanged(); + } + + if (filesAdded) { + emit linkedFilesChanged(); } } @@ -1200,17 +1228,22 @@ QString ChatRootView::generateChatFileName(const QString &shortMessage, const QS bool ChatRootView::hasImageAttachments(const QStringList &attachments) const { - static const QSet imageExtensions = { - "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg" - }; - for (const QString &filePath : attachments) { - QFileInfo fileInfo(filePath); - if (imageExtensions.contains(fileInfo.suffix().toLower())) { + if (isImageFile(filePath)) { return true; } } return false; } +bool ChatRootView::isImageFile(const QString &filePath) const +{ + static const QSet imageExtensions = { + "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg" + }; + + QFileInfo fileInfo(filePath); + return imageExtensions.contains(fileInfo.suffix().toLower()); +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 7d45d8d..fca6079 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -81,10 +81,13 @@ public: QStringList linkedFiles() const; Q_INVOKABLE void showAttachFilesDialog(); + Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths); Q_INVOKABLE void removeFileFromAttachList(int index); Q_INVOKABLE void showLinkFilesDialog(); + Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths); Q_INVOKABLE void removeFileFromLinkList(int index); Q_INVOKABLE void showAddImageDialog(); + Q_INVOKABLE bool isImageFile(const QString &filePath) const; Q_INVOKABLE void calculateMessageTokensCount(const QString &message); Q_INVOKABLE void setIsSyncOpenFiles(bool state); Q_INVOKABLE void openChatHistoryFolder(); diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index ce25326..777f6aa 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -58,6 +58,18 @@ ChatRootView { color: palette.window } + SplitDropZone { + anchors.fill: parent + + onFilesDroppedToAttach: (filePaths) => { + root.addFilesToAttachList(filePaths) + } + + onFilesDroppedToLink: (filePaths) => { + root.addFilesToLinkList(filePaths) + } + } + ColumnLayout { anchors.fill: parent spacing: 0 diff --git a/ChatView/qml/controls/SplitDropZone.qml b/ChatView/qml/controls/SplitDropZone.qml new file mode 100644 index 0000000..24b22d6 --- /dev/null +++ b/ChatView/qml/controls/SplitDropZone.qml @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2025 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 + +Item { + id: root + + signal filesDroppedToAttach(var filePaths) + signal filesDroppedToLink(var filePaths) + + property string activeZone: "" + + Item { + id: splitDropOverlay + + anchors.fill: parent + visible: false + z: 999 + + Rectangle { + anchors.fill: parent + color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6) + } + + Rectangle { + id: leftZone + + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: parent.width / 2 + color: root.activeZone === "left" + ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15) + border.width: root.activeZone === "left" ? 3 : 2 + border.color: root.activeZone === "left" + ? palette.highlight + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5) + + Column { + anchors.centerIn: parent + spacing: 15 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Attach") + font.pixelSize: 24 + font.bold: true + color: root.activeZone === "left" ? palette.highlightedText : palette.text + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Images & Text Files") + font.pixelSize: 14 + color: root.activeZone === "left" ? palette.highlightedText : palette.text + opacity: 0.8 + } + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + } + + Rectangle { + id: rightZone + + anchors { + right: parent.right + top: parent.top + bottom: parent.bottom + } + width: parent.width / 2 + color: root.activeZone === "right" + ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15) + border.width: root.activeZone === "right" ? 3 : 2 + border.color: root.activeZone === "right" + ? palette.highlight + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5) + + Column { + anchors.centerIn: parent + spacing: 15 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("LINK") + font.pixelSize: 24 + font.bold: true + color: root.activeZone === "right" ? palette.highlightedText : palette.text + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Text Files") + font.pixelSize: 14 + color: root.activeZone === "right" ? palette.highlightedText : palette.text + opacity: 0.8 + } + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + + Behavior on border.width { + NumberAnimation { duration: 150 } + } + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + } + + Rectangle { + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + bottom: parent.bottom + } + width: 2 + color: palette.mid + opacity: 0.4 + } + + MouseArea { + id: leftDropArea + + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: parent.width / 2 + hoverEnabled: true + + onEntered: { + root.activeZone = "left" + } + } + + MouseArea { + id: rightDropArea + + anchors { + right: parent.right + top: parent.top + bottom: parent.bottom + } + width: parent.width / 2 + hoverEnabled: true + + onEntered: { + root.activeZone = "right" + } + } + } + + DropArea { + id: globalDropArea + + anchors.fill: parent + + onEntered: (drag) => { + if (drag.hasUrls) { + splitDropOverlay.visible = true + root.activeZone = "" + } + } + + onExited: { + splitDropOverlay.visible = false + root.activeZone = "" + } + + onPositionChanged: (drag) => { + if (drag.x < globalDropArea.width / 2) { + root.activeZone = "left" + } else { + root.activeZone = "right" + } + } + + onDropped: (drop) => { + var targetZone = root.activeZone + splitDropOverlay.visible = false + root.activeZone = "" + + if (drop.hasUrls) { + var filePaths = [] + for (var i = 0; i < drop.urls.length; i++) { + var url = drop.urls[i].toString() + if (url.startsWith("file://")) { + filePaths.push(decodeURIComponent(url.replace(/^file:\/\//, ''))) + } + } + + if (filePaths.length > 0) { + if (targetZone === "right") { + root.filesDroppedToLink(filePaths) + } else { + root.filesDroppedToAttach(filePaths) + } + } + } + } + } +} +