mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add chat to editor view and refactor current openning
This commit is contained in:
@@ -115,6 +115,9 @@ add_qtc_plugin(QodeAssist
|
|||||||
QodeAssistClient.hpp QodeAssistClient.cpp
|
QodeAssistClient.hpp QodeAssistClient.cpp
|
||||||
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
chat/ChatOutputPane.h chat/ChatOutputPane.cpp
|
||||||
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
|
chat/NavigationPanel.hpp chat/NavigationPanel.cpp
|
||||||
|
chat/ChatDocument.hpp chat/ChatDocument.cpp
|
||||||
|
chat/ChatEditor.hpp chat/ChatEditor.cpp
|
||||||
|
chat/ChatEditorFactory.hpp chat/ChatEditorFactory.cpp
|
||||||
ConfigurationManager.hpp ConfigurationManager.cpp
|
ConfigurationManager.hpp ConfigurationManager.cpp
|
||||||
CodeHandler.hpp CodeHandler.cpp
|
CodeHandler.hpp CodeHandler.cpp
|
||||||
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
UpdateStatusWidget.hpp UpdateStatusWidget.cpp
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ qt_add_qml_module(QodeAssistChatView
|
|||||||
InputTokenCounter.hpp InputTokenCounter.cpp
|
InputTokenCounter.hpp InputTokenCounter.cpp
|
||||||
ChatHistoryStore.hpp ChatHistoryStore.cpp
|
ChatHistoryStore.hpp ChatHistoryStore.cpp
|
||||||
FileMentionItem.hpp FileMentionItem.cpp
|
FileMentionItem.hpp FileMentionItem.cpp
|
||||||
|
SessionFileRegistry.hpp SessionFileRegistry.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(QodeAssistChatView
|
target_link_libraries(QodeAssistChatView
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "ChatRootView.hpp"
|
#include "ChatRootView.hpp"
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
@@ -10,8 +11,12 @@
|
|||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQmlEngine>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
|
|
||||||
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
|
#include <coreplugin/actionmanager/command.h>
|
||||||
#include <coreplugin/editormanager/editormanager.h>
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
#include <projectexplorer/project.h>
|
#include <projectexplorer/project.h>
|
||||||
#include <projectexplorer/projectexplorer.h>
|
#include <projectexplorer/projectexplorer.h>
|
||||||
@@ -19,6 +24,8 @@
|
|||||||
#include <utils/theme/theme.h>
|
#include <utils/theme/theme.h>
|
||||||
#include <utils/utilsicons.h>
|
#include <utils/utilsicons.h>
|
||||||
|
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
|
||||||
#include "AgentRoleController.hpp"
|
#include "AgentRoleController.hpp"
|
||||||
#include "ChatAssistantSettings.hpp"
|
#include "ChatAssistantSettings.hpp"
|
||||||
#include "ChatConfigurationController.hpp"
|
#include "ChatConfigurationController.hpp"
|
||||||
@@ -30,11 +37,20 @@
|
|||||||
#include "SettingsConstants.hpp"
|
#include "SettingsConstants.hpp"
|
||||||
#include "Logger.hpp"
|
#include "Logger.hpp"
|
||||||
#include "ProvidersManager.hpp"
|
#include "ProvidersManager.hpp"
|
||||||
|
#include "SessionFileRegistry.hpp"
|
||||||
#include "context/ContextManager.hpp"
|
#include "context/ContextManager.hpp"
|
||||||
#include "pluginllmcore/RulesLoader.hpp"
|
#include "pluginllmcore/RulesLoader.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool isChatEditor(Core::IEditor *editor)
|
||||||
|
{
|
||||||
|
return editor && editor->document()
|
||||||
|
&& editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
ChatRootView::ChatRootView(QQuickItem *parent)
|
ChatRootView::ChatRootView(QQuickItem *parent)
|
||||||
: QQuickItem(parent)
|
: QQuickItem(parent)
|
||||||
, m_chatModel(new ChatModel(this))
|
, m_chatModel(new ChatModel(this))
|
||||||
@@ -278,6 +294,25 @@ ChatRootView::ChatRootView(QQuickItem *parent)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatRootView::~ChatRootView()
|
||||||
|
{
|
||||||
|
if (m_sessionFileRegistry && !m_recentFilePath.isEmpty()) {
|
||||||
|
m_sessionFileRegistry->release(m_recentFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionFileRegistry *ChatRootView::sessionFileRegistry() const
|
||||||
|
{
|
||||||
|
if (!m_sessionFileRegistryResolved) {
|
||||||
|
m_sessionFileRegistryResolved = true;
|
||||||
|
if (auto context = qmlContext(this)) {
|
||||||
|
m_sessionFileRegistry = qobject_cast<SessionFileRegistry *>(
|
||||||
|
context->contextProperty("sessionFileRegistry").value<QObject *>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m_sessionFileRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
ChatModel *ChatRootView::chatModel() const
|
ChatModel *ChatRootView::chatModel() const
|
||||||
{
|
{
|
||||||
return m_chatModel;
|
return m_chatModel;
|
||||||
@@ -341,6 +376,9 @@ void ChatRootView::dispatchSend(
|
|||||||
{
|
{
|
||||||
if (m_recentFilePath.isEmpty()) {
|
if (m_recentFilePath.isEmpty()) {
|
||||||
QString filePath = getAutosaveFilePath(message, attachments);
|
QString filePath = getAutosaveFilePath(message, attachments);
|
||||||
|
if (auto registry = sessionFileRegistry()) {
|
||||||
|
filePath = registry->uniqueFreePath(filePath);
|
||||||
|
}
|
||||||
if (!filePath.isEmpty()) {
|
if (!filePath.isEmpty()) {
|
||||||
setRecentFilePath(filePath);
|
setRecentFilePath(filePath);
|
||||||
LOG_MESSAGE(QString("Set chat file path for new chat: %1").arg(filePath));
|
LOG_MESSAGE(QString("Set chat file path for new chat: %1").arg(filePath));
|
||||||
@@ -402,6 +440,15 @@ QString ChatRootView::currentTemplate() const
|
|||||||
|
|
||||||
void ChatRootView::saveHistory(const QString &filePath)
|
void ChatRootView::saveHistory(const QString &filePath)
|
||||||
{
|
{
|
||||||
|
if (filePath != m_recentFilePath) {
|
||||||
|
if (auto registry = sessionFileRegistry(); registry && registry->isLocked(filePath)) {
|
||||||
|
m_lastErrorMessage
|
||||||
|
= tr("This chat file is already in use by another QodeAssist chat session.");
|
||||||
|
emit lastErrorMessageChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto result = m_historyStore->save(filePath);
|
auto result = m_historyStore->save(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
LOG_MESSAGE(QString("Failed to save chat history: %1").arg(result.errorMessage));
|
LOG_MESSAGE(QString("Failed to save chat history: %1").arg(result.errorMessage));
|
||||||
@@ -412,6 +459,15 @@ void ChatRootView::saveHistory(const QString &filePath)
|
|||||||
|
|
||||||
void ChatRootView::loadHistory(const QString &filePath)
|
void ChatRootView::loadHistory(const QString &filePath)
|
||||||
{
|
{
|
||||||
|
if (filePath != m_recentFilePath) {
|
||||||
|
if (auto registry = sessionFileRegistry(); registry && registry->isLocked(filePath)) {
|
||||||
|
m_lastErrorMessage
|
||||||
|
= tr("This chat is already open in another QodeAssist chat session.");
|
||||||
|
emit lastErrorMessageChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto result = m_historyStore->load(filePath);
|
auto result = m_historyStore->load(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage));
|
LOG_MESSAGE(QString("Failed to load chat history: %1").arg(result.errorMessage));
|
||||||
@@ -446,11 +502,18 @@ void ChatRootView::autosave()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString filePath = getAutosaveFilePath();
|
if (m_recentFilePath.isEmpty()) {
|
||||||
if (!filePath.isEmpty()) {
|
QString filePath = getAutosaveFilePath();
|
||||||
m_historyStore->save(filePath);
|
if (auto registry = sessionFileRegistry()) {
|
||||||
|
filePath = registry->uniqueFreePath(filePath);
|
||||||
|
}
|
||||||
|
if (filePath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRecentFilePath(filePath);
|
setRecentFilePath(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_historyStore->save(m_recentFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ChatRootView::getAutosaveFilePath() const
|
QString ChatRootView::getAutosaveFilePath() const
|
||||||
@@ -671,6 +734,76 @@ void ChatRootView::openFileInEditor(const QString &filePath)
|
|||||||
Core::EditorManager::openEditor(Utils::FilePath::fromString(filePath));
|
Core::EditorManager::openEditor(Utils::FilePath::fromString(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatRootView::triggerOpenChatCommand(Utils::Id commandId)
|
||||||
|
{
|
||||||
|
if (auto command = Core::ActionManager::command(commandId)) {
|
||||||
|
if (auto action = command->action())
|
||||||
|
action->trigger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatRootView::handOffSession()
|
||||||
|
{
|
||||||
|
if (m_chatModel->rowCount() > 0) {
|
||||||
|
if (m_recentFilePath.isEmpty()) {
|
||||||
|
QString filePath = getAutosaveFilePath();
|
||||||
|
if (auto registry = sessionFileRegistry())
|
||||||
|
filePath = registry->uniqueFreePath(filePath);
|
||||||
|
if (!filePath.isEmpty())
|
||||||
|
setRecentFilePath(filePath);
|
||||||
|
}
|
||||||
|
if (!m_recentFilePath.isEmpty())
|
||||||
|
m_historyStore->save(m_recentFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto registry = sessionFileRegistry(); registry && !m_recentFilePath.isEmpty())
|
||||||
|
registry->setPendingChatFile(m_recentFilePath);
|
||||||
|
|
||||||
|
setRecentFilePath(QString{});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatRootView::consumePendingChatFile()
|
||||||
|
{
|
||||||
|
if (auto registry = sessionFileRegistry()) {
|
||||||
|
const QString pending = registry->takePendingChatFile();
|
||||||
|
if (!pending.isEmpty())
|
||||||
|
loadHistory(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatRootView::relocateToSplit()
|
||||||
|
{
|
||||||
|
handOffSession();
|
||||||
|
triggerOpenChatCommand(Constants::QODE_ASSIST_SHOW_CHAT_ACTION);
|
||||||
|
clearMessages();
|
||||||
|
clearAttachmentFiles();
|
||||||
|
emit closeHostRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatRootView::relocateToWindow()
|
||||||
|
{
|
||||||
|
handOffSession();
|
||||||
|
triggerOpenChatCommand(Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION);
|
||||||
|
clearMessages();
|
||||||
|
clearAttachmentFiles();
|
||||||
|
emit closeHostRequested();
|
||||||
|
|
||||||
|
// Closing the source split raises the main window; re-raise the chat window once that
|
||||||
|
// queued teardown has run. The registry outlives this view, which the split close deletes.
|
||||||
|
if (auto registry = sessionFileRegistry()) {
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
registry,
|
||||||
|
[] {
|
||||||
|
if (auto command = Core::ActionManager::command(
|
||||||
|
Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION)) {
|
||||||
|
if (auto action = command->action())
|
||||||
|
action->trigger();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ChatRootView::updateInputTokensCount()
|
void ChatRootView::updateInputTokensCount()
|
||||||
{
|
{
|
||||||
m_tokenCounter->recompute();
|
m_tokenCounter->recompute();
|
||||||
@@ -688,6 +821,10 @@ bool ChatRootView::isSyncOpenFiles() const
|
|||||||
|
|
||||||
void ChatRootView::onEditorAboutToClose(Core::IEditor *editor)
|
void ChatRootView::onEditorAboutToClose(Core::IEditor *editor)
|
||||||
{
|
{
|
||||||
|
if (isChatEditor(editor)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (auto document = editor->document(); document && isSyncOpenFiles()) {
|
if (auto document = editor->document(); document && isSyncOpenFiles()) {
|
||||||
QString filePath = document->filePath().toFSPathString();
|
QString filePath = document->filePath().toFSPathString();
|
||||||
m_linkedFiles.removeOne(filePath);
|
m_linkedFiles.removeOne(filePath);
|
||||||
@@ -703,6 +840,10 @@ void ChatRootView::onEditorAboutToClose(Core::IEditor *editor)
|
|||||||
|
|
||||||
void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor)
|
void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor)
|
||||||
{
|
{
|
||||||
|
if (isChatEditor(editor)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (auto document = editor->document(); document && isSyncOpenFiles()) {
|
if (auto document = editor->document(); document && isSyncOpenFiles()) {
|
||||||
QString filePath = document->filePath().toFSPathString();
|
QString filePath = document->filePath().toFSPathString();
|
||||||
if (!m_linkedFiles.contains(filePath) && !shouldIgnoreFileForAttach(document->filePath())) {
|
if (!m_linkedFiles.contains(filePath) && !shouldIgnoreFileForAttach(document->filePath())) {
|
||||||
@@ -714,6 +855,10 @@ void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor)
|
|||||||
|
|
||||||
void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath)
|
void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath)
|
||||||
{
|
{
|
||||||
|
if (isChatEditor(editor)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editor && editor->document()) {
|
if (editor && editor->document()) {
|
||||||
m_currentEditors.append(editor);
|
m_currentEditors.append(editor);
|
||||||
emit openFilesChanged();
|
emit openFilesChanged();
|
||||||
@@ -732,12 +877,23 @@ QString ChatRootView::chatFilePath() const
|
|||||||
|
|
||||||
void ChatRootView::setRecentFilePath(const QString &filePath)
|
void ChatRootView::setRecentFilePath(const QString &filePath)
|
||||||
{
|
{
|
||||||
if (m_recentFilePath != filePath) {
|
if (m_recentFilePath == filePath) {
|
||||||
m_recentFilePath = filePath;
|
return;
|
||||||
m_clientInterface->setChatFilePath(filePath);
|
|
||||||
m_fileManager->setChatFilePath(filePath);
|
|
||||||
emit chatFileNameChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auto registry = sessionFileRegistry()) {
|
||||||
|
if (!m_recentFilePath.isEmpty()) {
|
||||||
|
registry->release(m_recentFilePath);
|
||||||
|
}
|
||||||
|
if (!filePath.isEmpty()) {
|
||||||
|
registry->lock(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_recentFilePath = filePath;
|
||||||
|
m_clientInterface->setChatFilePath(filePath);
|
||||||
|
m_fileManager->setChatFilePath(filePath);
|
||||||
|
emit chatFileNameChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
|
bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPointer>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class ChatConfigurationController;
|
|||||||
class FileEditController;
|
class FileEditController;
|
||||||
class InputTokenCounter;
|
class InputTokenCounter;
|
||||||
class ChatHistoryStore;
|
class ChatHistoryStore;
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatRootView : public QQuickItem
|
class ChatRootView : public QQuickItem
|
||||||
{
|
{
|
||||||
@@ -62,6 +64,7 @@ class ChatRootView : public QQuickItem
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
ChatRootView(QQuickItem *parent = nullptr);
|
ChatRootView(QQuickItem *parent = nullptr);
|
||||||
|
~ChatRootView() override;
|
||||||
|
|
||||||
ChatModel *chatModel() const;
|
ChatModel *chatModel() const;
|
||||||
QString currentTemplate() const;
|
QString currentTemplate() const;
|
||||||
@@ -96,6 +99,11 @@ public:
|
|||||||
|
|
||||||
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
Q_INVOKABLE void openFileInEditor(const QString &filePath);
|
||||||
|
|
||||||
|
Q_INVOKABLE void relocateToSplit();
|
||||||
|
Q_INVOKABLE void relocateToWindow();
|
||||||
|
|
||||||
|
void consumePendingChatFile();
|
||||||
|
|
||||||
Q_INVOKABLE void updateInputTokensCount();
|
Q_INVOKABLE void updateInputTokensCount();
|
||||||
int inputTokensCount() const;
|
int inputTokensCount() const;
|
||||||
|
|
||||||
@@ -216,7 +224,11 @@ signals:
|
|||||||
|
|
||||||
void openFilesChanged();
|
void openFilesChanged();
|
||||||
|
|
||||||
|
void closeHostRequested();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void triggerOpenChatCommand(Utils::Id commandId);
|
||||||
|
void handOffSession();
|
||||||
bool deferSendForAutoCompress(
|
bool deferSendForAutoCompress(
|
||||||
const QString &message,
|
const QString &message,
|
||||||
const QStringList &attachments,
|
const QStringList &attachments,
|
||||||
@@ -231,6 +243,8 @@ private:
|
|||||||
bool useThinking);
|
bool useThinking);
|
||||||
bool hasImageAttachments(const QStringList &attachments) const;
|
bool hasImageAttachments(const QStringList &attachments) const;
|
||||||
|
|
||||||
|
SessionFileRegistry *sessionFileRegistry() const;
|
||||||
|
|
||||||
ChatModel *m_chatModel;
|
ChatModel *m_chatModel;
|
||||||
PluginLLMCore::PromptProviderChat m_promptProvider;
|
PluginLLMCore::PromptProviderChat m_promptProvider;
|
||||||
ClientInterface *m_clientInterface;
|
ClientInterface *m_clientInterface;
|
||||||
@@ -263,6 +277,8 @@ private:
|
|||||||
FileEditController *m_fileEditController;
|
FileEditController *m_fileEditController;
|
||||||
InputTokenCounter *m_tokenCounter;
|
InputTokenCounter *m_tokenCounter;
|
||||||
ChatHistoryStore *m_historyStore;
|
ChatHistoryStore *m_historyStore;
|
||||||
|
mutable QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||||
|
mutable bool m_sessionFileRegistryResolved = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
#include <coreplugin/actionmanager/command.h>
|
#include <coreplugin/actionmanager/command.h>
|
||||||
#include <logger/Logger.hpp>
|
#include <logger/Logger.hpp>
|
||||||
|
|
||||||
|
#include "ChatRootView.hpp"
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "SessionFileRegistry.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::WindowSystemMenuHint
|
||||||
@@ -24,7 +26,7 @@ constexpr Qt::WindowFlags baseFlags = Qt::Window | Qt::WindowTitleHint | Qt::Win
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatView::ChatView(QQmlEngine* engine)
|
ChatView::ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||||
: QQuickView{engine, nullptr}
|
: QQuickView{engine, nullptr}
|
||||||
, m_isPin(false)
|
, m_isPin(false)
|
||||||
{
|
{
|
||||||
@@ -33,12 +35,23 @@ ChatView::ChatView(QQmlEngine* engine)
|
|||||||
{
|
{
|
||||||
auto context = new QQmlContext{engine, this};
|
auto context = new QQmlContext{engine, this};
|
||||||
context->setContextProperty("_chatview", this);
|
context->setContextProperty("_chatview", this);
|
||||||
|
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||||
|
|
||||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||||
auto rootItem = component->create(context);
|
auto rootItem = component->create(context);
|
||||||
|
|
||||||
setContent(component->url(), component, rootItem);
|
setContent(component->url(), component, rootItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auto rootView = qobject_cast<ChatRootView *>(rootObject())) {
|
||||||
|
connect(
|
||||||
|
rootView,
|
||||||
|
&ChatRootView::closeHostRequested,
|
||||||
|
this,
|
||||||
|
&QWindow::close,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
|
||||||
setResizeMode(QQuickView::SizeRootObjectToView);
|
setResizeMode(QQuickView::SizeRootObjectToView);
|
||||||
setMinimumSize({400, 300});
|
setMinimumSize({400, 300});
|
||||||
setFlags(baseFlags);
|
setFlags(baseFlags);
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatView : public QQuickView
|
class ChatView : public QQuickView
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
Q_PROPERTY(bool isPin READ isPin WRITE setIsPin NOTIFY isPinChanged FINAL)
|
||||||
public:
|
public:
|
||||||
ChatView(QQmlEngine* engine);
|
ChatView(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||||
|
|
||||||
bool isPin() const;
|
bool isPin() const;
|
||||||
void setIsPin(bool newIsPin);
|
void setIsPin(bool newIsPin);
|
||||||
|
|||||||
@@ -12,15 +12,17 @@
|
|||||||
#include <coreplugin/icore.h>
|
#include <coreplugin/icore.h>
|
||||||
|
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "SessionFileRegistry.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatWidget::ChatWidget(QQmlEngine* engine, QWidget *parent)
|
ChatWidget::ChatWidget(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QWidget *parent)
|
||||||
: QQuickWidget{engine, parent}
|
: QQuickWidget{engine, parent}
|
||||||
{
|
{
|
||||||
/// @note setup quick view content
|
/// @note setup quick view content
|
||||||
{
|
{
|
||||||
auto context = new QQmlContext{engine, this};
|
auto context = new QQmlContext{engine, this};
|
||||||
|
context->setContextProperty("sessionFileRegistry", sessionFileRegistry);
|
||||||
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
auto component = new QQmlComponent{engine, QUrl{"qrc:/qt/qml/ChatView/qml/RootItem.qml"}, this};
|
||||||
auto rootItem = component->create(context);
|
auto rootItem = component->create(context);
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatWidget : public QQuickWidget
|
class ChatWidget : public QQuickWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatWidget(QQmlEngine* engine, QWidget *parent = nullptr);
|
explicit ChatWidget(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
QWidget *parent = nullptr);
|
||||||
~ChatWidget() = default;
|
~ChatWidget() = default;
|
||||||
|
|
||||||
Q_INVOKABLE void clear();
|
Q_INVOKABLE void clear();
|
||||||
|
|||||||
67
ChatView/SessionFileRegistry.cpp
Normal file
67
ChatView/SessionFileRegistry.cpp
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "SessionFileRegistry.hpp"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
SessionFileRegistry::SessionFileRegistry(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
bool SessionFileRegistry::isLocked(const QString &path) const
|
||||||
|
{
|
||||||
|
return !path.isEmpty() && m_lockedPaths.contains(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SessionFileRegistry::lock(const QString &path)
|
||||||
|
{
|
||||||
|
if (path.isEmpty() || m_lockedPaths.contains(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_lockedPaths.insert(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionFileRegistry::release(const QString &path)
|
||||||
|
{
|
||||||
|
m_lockedPaths.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SessionFileRegistry::setPendingChatFile(const QString &path)
|
||||||
|
{
|
||||||
|
m_pendingChatFile = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SessionFileRegistry::takePendingChatFile()
|
||||||
|
{
|
||||||
|
return std::exchange(m_pendingChatFile, QString{});
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SessionFileRegistry::uniqueFreePath(const QString &desiredPath) const
|
||||||
|
{
|
||||||
|
if (desiredPath.isEmpty() || !m_lockedPaths.contains(desiredPath)) {
|
||||||
|
return desiredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo info(desiredPath);
|
||||||
|
const QString dir = info.path();
|
||||||
|
const QString base = info.completeBaseName();
|
||||||
|
const QString suffix = info.suffix();
|
||||||
|
|
||||||
|
for (int counter = 2;; ++counter) {
|
||||||
|
QString candidate = dir + '/' + base + '_' + QString::number(counter);
|
||||||
|
if (!suffix.isEmpty()) {
|
||||||
|
candidate += '.' + suffix;
|
||||||
|
}
|
||||||
|
if (!m_lockedPaths.contains(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
38
ChatView/SessionFileRegistry.hpp
Normal file
38
ChatView/SessionFileRegistry.hpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
// Shared registry of chat session (autosave) file paths that are currently held by a live
|
||||||
|
// chat instance. Lets every chat view — bottom pane, navigation panel, editor split — claim
|
||||||
|
// a unique history file so two sessions never autosave into the same path.
|
||||||
|
class SessionFileRegistry : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SessionFileRegistry(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool isLocked(const QString &path) const;
|
||||||
|
bool lock(const QString &path);
|
||||||
|
void release(const QString &path);
|
||||||
|
|
||||||
|
QString uniqueFreePath(const QString &desiredPath) const;
|
||||||
|
|
||||||
|
// Handoff slot for relocating a live chat between hosts (split <-> window): the source
|
||||||
|
// chat stores its history file here, the freshly created host picks it up exactly once.
|
||||||
|
void setPendingChatFile(const QString &path);
|
||||||
|
QString takePendingChatFile();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSet<QString> m_lockedPaths;
|
||||||
|
QString m_pendingChatFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
@@ -116,6 +116,17 @@ ChatRootView {
|
|||||||
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false
|
||||||
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
onCheckedChanged: _chatview.isPin = topBar.pinButton.checked
|
||||||
}
|
}
|
||||||
|
relocateButton {
|
||||||
|
ToolTip.text: (typeof _chatview !== 'undefined')
|
||||||
|
? qsTr("Move this chat to an editor split")
|
||||||
|
: qsTr("Move this chat to a separate window")
|
||||||
|
onClicked: {
|
||||||
|
if (typeof _chatview !== 'undefined')
|
||||||
|
root.relocateToSplit()
|
||||||
|
else
|
||||||
|
root.relocateToWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
toolsButton {
|
toolsButton {
|
||||||
checked: root.useTools
|
checked: root.useTools
|
||||||
onCheckedChanged: {
|
onCheckedChanged: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Rectangle {
|
|||||||
property alias recentPath: recentPathId
|
property alias recentPath: recentPathId
|
||||||
property alias openChatHistory: openChatHistoryId
|
property alias openChatHistory: openChatHistoryId
|
||||||
property alias pinButton: pinButtonId
|
property alias pinButton: pinButtonId
|
||||||
|
property alias relocateButton: relocateButtonId
|
||||||
property alias contextButton: contextButtonId
|
property alias contextButton: contextButtonId
|
||||||
property alias toolsButton: toolsButtonId
|
property alias toolsButton: toolsButtonId
|
||||||
property alias thinkingMode: thinkingModeId
|
property alias thinkingMode: thinkingModeId
|
||||||
@@ -61,6 +62,21 @@ Rectangle {
|
|||||||
: qsTr("Pin chat window to the top")
|
: qsTr("Pin chat window to the top")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QoAButton {
|
||||||
|
id: relocateButtonId
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
icon {
|
||||||
|
source: "qrc:/qt/qml/ChatView/icons/open-in-editor.svg"
|
||||||
|
color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
|
||||||
|
height: 15
|
||||||
|
width: 15
|
||||||
|
}
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.delay: 250
|
||||||
|
}
|
||||||
|
|
||||||
QoAComboBox {
|
QoAComboBox {
|
||||||
id: configSelectorId
|
id: configSelectorId
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const char QODE_ASSIST_REQUEST_SUGGESTION[] = "QodeAssist.RequestSuggestion";
|
|||||||
|
|
||||||
const char QODE_ASSIST_CHAT_CONTEXT[] = "QodeAssist.ChatContext";
|
const char QODE_ASSIST_CHAT_CONTEXT[] = "QodeAssist.ChatContext";
|
||||||
const char QODE_ASSIST_CHAT_NAV_ID[] = "QodeAssistChat";
|
const char QODE_ASSIST_CHAT_NAV_ID[] = "QodeAssistChat";
|
||||||
|
const char QODE_ASSIST_CHAT_EDITOR_ID[] = "QodeAssist.ChatEditor";
|
||||||
|
|
||||||
|
const char QODE_ASSIST_SHOW_CHAT_ACTION[] = "QodeAssist.ShowChatView";
|
||||||
|
const char QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION[] = "QodeAssist.OpenChatWindow";
|
||||||
|
|
||||||
const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage";
|
const char QODE_ASSIST_CHAT_SEND_MESSAGE[] = "QodeAssist.Chat.SendMessage";
|
||||||
const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession";
|
const char QODE_ASSIST_CHAT_CLEAR_SESSION[] = "QodeAssist.Chat.ClearSession";
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "UpdateStatusWidget.hpp"
|
#include "UpdateStatusWidget.hpp"
|
||||||
|
|
||||||
|
#include <QMenu>
|
||||||
|
|
||||||
namespace QodeAssist {
|
namespace QodeAssist {
|
||||||
|
|
||||||
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
|
UpdateStatusWidget::UpdateStatusWidget(QWidget *parent)
|
||||||
@@ -57,6 +59,16 @@ void UpdateStatusWidget::setChatButtonAction(QAction *action)
|
|||||||
m_chatButton->setDefaultAction(action);
|
m_chatButton->setDefaultAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UpdateStatusWidget::setChatButtonMenu(QMenu *menu)
|
||||||
|
{
|
||||||
|
m_chatButton->setMenu(menu);
|
||||||
|
m_chatButton->setPopupMode(QToolButton::DelayedPopup);
|
||||||
|
m_chatButton->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
connect(m_chatButton, &QWidget::customContextMenuRequested, m_chatButton, [this, menu](const QPoint &pos) {
|
||||||
|
menu->exec(m_chatButton->mapToGlobal(pos));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QPushButton *UpdateStatusWidget::updateButton() const
|
QPushButton *UpdateStatusWidget::updateButton() const
|
||||||
{
|
{
|
||||||
return m_updateButton;
|
return m_updateButton;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
|
||||||
|
QT_BEGIN_NAMESPACE
|
||||||
|
class QMenu;
|
||||||
|
QT_END_NAMESPACE
|
||||||
|
|
||||||
namespace QodeAssist {
|
namespace QodeAssist {
|
||||||
|
|
||||||
class UpdateStatusWidget : public QFrame
|
class UpdateStatusWidget : public QFrame
|
||||||
@@ -21,6 +25,7 @@ public:
|
|||||||
void showUpdateAvailable(const QString &version);
|
void showUpdateAvailable(const QString &version);
|
||||||
void hideUpdateInfo();
|
void hideUpdateInfo();
|
||||||
void setChatButtonAction(QAction *action);
|
void setChatButtonAction(QAction *action);
|
||||||
|
void setChatButtonMenu(QMenu *menu);
|
||||||
|
|
||||||
QPushButton *updateButton() const;
|
QPushButton *updateButton() const;
|
||||||
|
|
||||||
|
|||||||
47
chat/ChatDocument.cpp
Normal file
47
chat/ChatDocument.cpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ChatDocument.hpp"
|
||||||
|
|
||||||
|
#include <utils/result.h>
|
||||||
|
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "QodeAssisttr.h"
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
ChatDocument::ChatDocument(QObject *parent)
|
||||||
|
: Core::IDocument(parent)
|
||||||
|
{
|
||||||
|
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||||
|
setMimeType("text/plain");
|
||||||
|
setTemporary(true);
|
||||||
|
setPreferredDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ChatDocument::contents() const
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Utils::Result<> ChatDocument::setContents(const QByteArray &)
|
||||||
|
{
|
||||||
|
return Utils::ResultOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDocument::isModified() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDocument::isSaveAsAllowed() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDocument::shouldAutoSave() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
27
chat/ChatDocument.hpp
Normal file
27
chat/ChatDocument.hpp
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <coreplugin/idocument.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
// Backing document for a chat editor view. The chat persists itself through its own
|
||||||
|
// autosave history file, so this document is purely a placeholder for the editor area
|
||||||
|
// and never participates in Qt Creator's save infrastructure.
|
||||||
|
class ChatDocument : public Core::IDocument
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ChatDocument(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QByteArray contents() const override;
|
||||||
|
Utils::Result<> setContents(const QByteArray &contents) override;
|
||||||
|
bool isModified() const override;
|
||||||
|
bool isSaveAsAllowed() const override;
|
||||||
|
bool shouldAutoSave() const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
73
chat/ChatEditor.cpp
Normal file
73
chat/ChatEditor.cpp
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ChatEditor.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
|
#include <coreplugin/actionmanager/command.h>
|
||||||
|
#include <coreplugin/coreconstants.h>
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
|
||||||
|
#include "ChatDocument.hpp"
|
||||||
|
#include "ChatView/ChatRootView.hpp"
|
||||||
|
#include "ChatView/ChatWidget.hpp"
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
ChatEditor::ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||||
|
: m_engine(engine)
|
||||||
|
, m_sessionFileRegistry(sessionFileRegistry)
|
||||||
|
, m_document(new ChatDocument(this))
|
||||||
|
, m_chatWidget(new ChatWidget(engine, sessionFileRegistry))
|
||||||
|
{
|
||||||
|
setWidget(m_chatWidget);
|
||||||
|
setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||||
|
setDuplicateSupported(true);
|
||||||
|
|
||||||
|
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject())) {
|
||||||
|
connect(
|
||||||
|
rootView,
|
||||||
|
&ChatRootView::closeHostRequested,
|
||||||
|
this,
|
||||||
|
[this] {
|
||||||
|
Core::EditorManager::closeEditors({this});
|
||||||
|
if (auto command
|
||||||
|
= Core::ActionManager::command(Core::Constants::REMOVE_CURRENT_SPLIT)) {
|
||||||
|
if (auto action = command->action(); action && action->isEnabled())
|
||||||
|
action->trigger();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatEditor::consumePendingChatFile()
|
||||||
|
{
|
||||||
|
if (auto rootView = qobject_cast<ChatRootView *>(m_chatWidget->rootObject()))
|
||||||
|
rootView->consumePendingChatFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatEditor::~ChatEditor()
|
||||||
|
{
|
||||||
|
delete m_chatWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
Core::IDocument *ChatEditor::document() const
|
||||||
|
{
|
||||||
|
return m_document;
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *ChatEditor::toolBar()
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Core::IEditor *ChatEditor::duplicate()
|
||||||
|
{
|
||||||
|
return new ChatEditor(m_engine, m_sessionFileRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
39
chat/ChatEditor.hpp
Normal file
39
chat/ChatEditor.hpp
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <coreplugin/editormanager/ieditor.h>
|
||||||
|
|
||||||
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class ChatDocument;
|
||||||
|
class ChatWidget;
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
|
// Editor-area host for the chat. Each editor (including a split duplicate) owns its own
|
||||||
|
// ChatWidget and therefore its own independent chat session.
|
||||||
|
class ChatEditor : public Core::IEditor
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ChatEditor(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||||
|
~ChatEditor() override;
|
||||||
|
|
||||||
|
Core::IDocument *document() const override;
|
||||||
|
QWidget *toolBar() override;
|
||||||
|
Core::IEditor *duplicate() override;
|
||||||
|
|
||||||
|
void consumePendingChatFile();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QQmlEngine *m_engine;
|
||||||
|
SessionFileRegistry *m_sessionFileRegistry;
|
||||||
|
ChatDocument *m_document;
|
||||||
|
ChatWidget *m_chatWidget;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
20
chat/ChatEditorFactory.cpp
Normal file
20
chat/ChatEditorFactory.cpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "ChatEditorFactory.hpp"
|
||||||
|
|
||||||
|
#include "ChatEditor.hpp"
|
||||||
|
#include "QodeAssistConstants.hpp"
|
||||||
|
#include "QodeAssisttr.h"
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
ChatEditorFactory::ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||||
|
{
|
||||||
|
setId(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
|
||||||
|
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
|
setEditorCreator(
|
||||||
|
[engine, sessionFileRegistry] { return new ChatEditor(engine, sessionFileRegistry); });
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
20
chat/ChatEditorFactory.hpp
Normal file
20
chat/ChatEditorFactory.hpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <coreplugin/editormanager/ieditorfactory.h>
|
||||||
|
|
||||||
|
class QQmlEngine;
|
||||||
|
|
||||||
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
|
class ChatEditorFactory : public Core::IEditorFactory
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ChatEditorFactory(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Chat
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
ChatOutputPane::ChatOutputPane(QQmlEngine* engine, QObject *parent)
|
ChatOutputPane::ChatOutputPane(
|
||||||
|
QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry, QObject *parent)
|
||||||
: Core::IOutputPane(parent)
|
: Core::IOutputPane(parent)
|
||||||
, m_chatWidget{new ChatWidget{engine}}
|
, m_chatWidget{new ChatWidget{engine, sessionFileRegistry}}
|
||||||
{
|
{
|
||||||
setId("QodeAssistChat");
|
setId("QodeAssistChat");
|
||||||
setDisplayName(Tr::tr("QodeAssist Chat"));
|
setDisplayName(Tr::tr("QodeAssist Chat"));
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class ChatOutputPane : public Core::IOutputPane
|
class ChatOutputPane : public Core::IOutputPane
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ChatOutputPane(QQmlEngine* engine, QObject *parent = nullptr);
|
explicit ChatOutputPane(
|
||||||
|
QQmlEngine *engine,
|
||||||
|
SessionFileRegistry *sessionFileRegistry,
|
||||||
|
QObject *parent = nullptr);
|
||||||
~ChatOutputPane() override;
|
~ChatOutputPane() override;
|
||||||
|
|
||||||
QWidget *outputWidget(QWidget *parent) override;
|
QWidget *outputWidget(QWidget *parent) override;
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
#include "NavigationPanel.hpp"
|
#include "NavigationPanel.hpp"
|
||||||
|
|
||||||
#include "ChatView/ChatWidget.hpp"
|
#include "ChatView/ChatWidget.hpp"
|
||||||
|
#include "ChatView/SessionFileRegistry.hpp"
|
||||||
#include "QodeAssistConstants.hpp"
|
#include "QodeAssistConstants.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
NavigationPanel::NavigationPanel(QQmlEngine* engine)
|
NavigationPanel::NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry)
|
||||||
: m_engine{engine}
|
: m_engine{engine}
|
||||||
|
, m_sessionFileRegistry{sessionFileRegistry}
|
||||||
{
|
{
|
||||||
setDisplayName(tr("QodeAssist Chat"));
|
setDisplayName(tr("QodeAssist Chat"));
|
||||||
setPriority(500);
|
setPriority(500);
|
||||||
@@ -21,7 +23,7 @@ NavigationPanel::~NavigationPanel() {}
|
|||||||
|
|
||||||
Core::NavigationView NavigationPanel::createWidget()
|
Core::NavigationView NavigationPanel::createWidget()
|
||||||
{
|
{
|
||||||
return {.widget = new ChatWidget{m_engine}};
|
return {.widget = new ChatWidget{m_engine, m_sessionFileRegistry}};
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ class QQmlEngine;
|
|||||||
|
|
||||||
namespace QodeAssist::Chat {
|
namespace QodeAssist::Chat {
|
||||||
|
|
||||||
|
class SessionFileRegistry;
|
||||||
|
|
||||||
class NavigationPanel : public Core::INavigationWidgetFactory
|
class NavigationPanel : public Core::INavigationWidgetFactory
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit NavigationPanel(QQmlEngine* engine);
|
explicit NavigationPanel(QQmlEngine *engine, SessionFileRegistry *sessionFileRegistry);
|
||||||
~NavigationPanel();
|
~NavigationPanel();
|
||||||
|
|
||||||
Core::NavigationView createWidget() override;
|
Core::NavigationView createWidget() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QPointer<QQmlEngine> m_engine;
|
QPointer<QQmlEngine> m_engine;
|
||||||
|
QPointer<SessionFileRegistry> m_sessionFileRegistry;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace QodeAssist::Chat
|
} // namespace QodeAssist::Chat
|
||||||
|
|||||||
135
qodeassist.cpp
135
qodeassist.cpp
@@ -32,6 +32,8 @@
|
|||||||
#include "QodeAssistClient.hpp"
|
#include "QodeAssistClient.hpp"
|
||||||
#include "UpdateStatusWidget.hpp"
|
#include "UpdateStatusWidget.hpp"
|
||||||
#include "Version.hpp"
|
#include "Version.hpp"
|
||||||
|
#include "chat/ChatEditor.hpp"
|
||||||
|
#include "chat/ChatEditorFactory.hpp"
|
||||||
#include "chat/ChatOutputPane.h"
|
#include "chat/ChatOutputPane.h"
|
||||||
#include "chat/NavigationPanel.hpp"
|
#include "chat/NavigationPanel.hpp"
|
||||||
#include "context/DocumentReaderQtCreator.hpp"
|
#include "context/DocumentReaderQtCreator.hpp"
|
||||||
@@ -51,7 +53,11 @@
|
|||||||
#include "widgets/QuickRefactorDialog.hpp"
|
#include "widgets/QuickRefactorDialog.hpp"
|
||||||
#include <ChatView/ChatView.hpp>
|
#include <ChatView/ChatView.hpp>
|
||||||
#include <ChatView/ChatFileManager.hpp>
|
#include <ChatView/ChatFileManager.hpp>
|
||||||
|
#include <ChatView/ChatRootView.hpp>
|
||||||
#include <ChatView/ChatWidget.hpp>
|
#include <ChatView/ChatWidget.hpp>
|
||||||
|
#include <ChatView/SessionFileRegistry.hpp>
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
#include <QUuid>
|
||||||
#include <coreplugin/actionmanager/actioncontainer.h>
|
#include <coreplugin/actionmanager/actioncontainer.h>
|
||||||
#include <coreplugin/actionmanager/actionmanager.h>
|
#include <coreplugin/actionmanager/actionmanager.h>
|
||||||
#include <texteditor/textdocument.h>
|
#include <texteditor/textdocument.h>
|
||||||
@@ -86,6 +92,7 @@ public:
|
|||||||
if (m_navigationPanel) {
|
if (m_navigationPanel) {
|
||||||
delete m_navigationPanel;
|
delete m_navigationPanel;
|
||||||
}
|
}
|
||||||
|
delete m_chatEditorFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadTranslations()
|
void loadTranslations()
|
||||||
@@ -154,13 +161,15 @@ public:
|
|||||||
});
|
});
|
||||||
|
|
||||||
m_engine = new QQmlEngine{this};
|
m_engine = new QQmlEngine{this};
|
||||||
|
m_sessionFileRegistry = new Chat::SessionFileRegistry{this};
|
||||||
|
|
||||||
if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) {
|
if (Settings::chatAssistantSettings().enableChatInBottomToolBar()) {
|
||||||
m_chatOutputPane = new Chat::ChatOutputPane{m_engine};
|
m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry};
|
||||||
}
|
}
|
||||||
if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) {
|
if (Settings::chatAssistantSettings().enableChatInNavigationPanel()) {
|
||||||
m_navigationPanel = new Chat::NavigationPanel{m_engine};
|
m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry};
|
||||||
}
|
}
|
||||||
|
m_chatEditorFactory = new Chat::ChatEditorFactory{m_engine, m_sessionFileRegistry};
|
||||||
|
|
||||||
Settings::setupProjectPanel();
|
Settings::setupProjectPanel();
|
||||||
ConfigurationManager::instance().init();
|
ConfigurationManager::instance().init();
|
||||||
@@ -200,26 +209,23 @@ public:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ActionBuilder showChatViewAction(this, "QodeAssist.ShowChatView");
|
ActionBuilder showChatViewAction(this, Constants::QODE_ASSIST_SHOW_CHAT_ACTION);
|
||||||
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
|
const QKeySequence showChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_W);
|
||||||
showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
|
showChatViewAction.setDefaultKeySequence(showChatViewShortcut);
|
||||||
showChatViewAction.setToolTip(Tr::tr("Show QodeAssist Chat"));
|
showChatViewAction.setToolTip(Tr::tr("Open QodeAssist Chat in an editor split"));
|
||||||
showChatViewAction.setText(Tr::tr("Show QodeAssist Chat"));
|
showChatViewAction.setText(Tr::tr("Show QodeAssist Chat"));
|
||||||
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
showChatViewAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||||
showChatViewAction.addOnTriggered(this, [this] {
|
showChatViewAction.addOnTriggered(this, [this] { openChatInSplit(); });
|
||||||
if (!m_chatView) {
|
|
||||||
m_chatView.reset(new Chat::ChatView{m_engine});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_chatView->isVisible()) {
|
|
||||||
m_chatView->show();
|
|
||||||
}
|
|
||||||
|
|
||||||
m_chatView->raise();
|
|
||||||
m_chatView->requestActivate();
|
|
||||||
});
|
|
||||||
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
|
m_statusWidget->setChatButtonAction(showChatViewAction.contextAction());
|
||||||
|
|
||||||
|
m_chatButtonMenu = new QMenu(m_statusWidget);
|
||||||
|
connect(
|
||||||
|
m_chatButtonMenu,
|
||||||
|
&QMenu::aboutToShow,
|
||||||
|
this,
|
||||||
|
&QodeAssistPlugin::rebuildChatButtonMenu);
|
||||||
|
m_statusWidget->setChatButtonMenu(m_chatButtonMenu);
|
||||||
|
|
||||||
ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView");
|
ActionBuilder closeChatViewAction(this, "QodeAssist.CloseChatView");
|
||||||
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
|
const QKeySequence closeChatViewShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_S);
|
||||||
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
|
closeChatViewAction.setDefaultKeySequence(closeChatViewShortcut);
|
||||||
@@ -232,6 +238,12 @@ public:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ActionBuilder openChatWindowAction(this, Constants::QODE_ASSIST_OPEN_CHAT_WINDOW_ACTION);
|
||||||
|
openChatWindowAction.setText(Tr::tr("Open QodeAssist Chat in Separate Window"));
|
||||||
|
openChatWindowAction.setToolTip(Tr::tr("Open the QodeAssist chat in a separate window"));
|
||||||
|
openChatWindowAction.setIcon(QCODEASSIST_CHAT_ICON.icon());
|
||||||
|
openChatWindowAction.addOnTriggered(this, [this] { openChatInWindow(); });
|
||||||
|
|
||||||
ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
|
ActionBuilder sendMessageAction(this, Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
|
||||||
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
sendMessageAction.setContext(Core::Context(Constants::QODE_ASSIST_CHAT_CONTEXT));
|
||||||
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
|
sendMessageAction.setText(Tr::tr("Send QodeAssist Chat Message"));
|
||||||
@@ -295,6 +307,94 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void openChatInSplit()
|
||||||
|
{
|
||||||
|
if (auto splitCommand
|
||||||
|
= Core::ActionManager::command(Core::Constants::SPLIT_SIDE_BY_SIDE)) {
|
||||||
|
if (auto splitAction = splitCommand->action())
|
||||||
|
splitAction->trigger();
|
||||||
|
}
|
||||||
|
QString title = Tr::tr("QodeAssist Chat");
|
||||||
|
Core::IEditor *editor = Core::EditorManager::openEditorWithContents(
|
||||||
|
Constants::QODE_ASSIST_CHAT_EDITOR_ID, &title, {}, QUuid::createUuid().toString());
|
||||||
|
if (auto chatEditor = qobject_cast<Chat::ChatEditor *>(editor))
|
||||||
|
chatEditor->consumePendingChatFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void openChatInWindow()
|
||||||
|
{
|
||||||
|
if (!m_chatView)
|
||||||
|
m_chatView.reset(new Chat::ChatView{m_engine, m_sessionFileRegistry});
|
||||||
|
|
||||||
|
if (!m_chatView->isVisible())
|
||||||
|
m_chatView->show();
|
||||||
|
|
||||||
|
m_chatView->raise();
|
||||||
|
m_chatView->requestActivate();
|
||||||
|
|
||||||
|
if (auto rootView = qobject_cast<Chat::ChatRootView *>(m_chatView->rootObject()))
|
||||||
|
rootView->consumePendingChatFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChatInBottomPaneEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled && !m_chatOutputPane)
|
||||||
|
m_chatOutputPane = new Chat::ChatOutputPane{m_engine, m_sessionFileRegistry};
|
||||||
|
else if (!enabled && m_chatOutputPane)
|
||||||
|
delete m_chatOutputPane;
|
||||||
|
|
||||||
|
Settings::chatAssistantSettings().enableChatInBottomToolBar.setValue(enabled);
|
||||||
|
Settings::chatAssistantSettings().writeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChatInSidebarEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled && !m_navigationPanel)
|
||||||
|
m_navigationPanel = new Chat::NavigationPanel{m_engine, m_sessionFileRegistry};
|
||||||
|
else if (!enabled && m_navigationPanel)
|
||||||
|
delete m_navigationPanel;
|
||||||
|
|
||||||
|
Settings::chatAssistantSettings().enableChatInNavigationPanel.setValue(enabled);
|
||||||
|
Settings::chatAssistantSettings().writeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void rebuildChatButtonMenu()
|
||||||
|
{
|
||||||
|
if (!m_chatButtonMenu)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_chatButtonMenu->clear();
|
||||||
|
|
||||||
|
QAction *paneAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Bottom Panel"));
|
||||||
|
paneAction->setCheckable(true);
|
||||||
|
paneAction->setChecked(m_chatOutputPane != nullptr);
|
||||||
|
connect(paneAction, &QAction::toggled, this, [this](bool on) {
|
||||||
|
setChatInBottomPaneEnabled(on);
|
||||||
|
});
|
||||||
|
|
||||||
|
QAction *sidebarAction = m_chatButtonMenu->addAction(Tr::tr("Chat in Sidebar"));
|
||||||
|
sidebarAction->setCheckable(true);
|
||||||
|
sidebarAction->setChecked(m_navigationPanel != nullptr);
|
||||||
|
connect(sidebarAction, &QAction::toggled, this, [this](bool on) {
|
||||||
|
setChatInSidebarEnabled(on);
|
||||||
|
});
|
||||||
|
|
||||||
|
m_chatButtonMenu->addSeparator();
|
||||||
|
|
||||||
|
if (m_chatView && m_chatView->isVisible()) {
|
||||||
|
QAction *splitAction = m_chatButtonMenu->addAction(Tr::tr("Open Chat in Split"));
|
||||||
|
connect(splitAction, &QAction::triggered, this, [this] {
|
||||||
|
if (m_chatView)
|
||||||
|
m_chatView->close();
|
||||||
|
openChatInSplit();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
QAction *windowAction
|
||||||
|
= m_chatButtonMenu->addAction(Tr::tr("Open Chat in Separate Window"));
|
||||||
|
connect(windowAction, &QAction::triggered, this, [this] { openChatInWindow(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void checkForUpdates()
|
void checkForUpdates()
|
||||||
{
|
{
|
||||||
connect(
|
connect(
|
||||||
@@ -321,6 +421,9 @@ private:
|
|||||||
RequestPerformanceLogger m_performanceLogger;
|
RequestPerformanceLogger m_performanceLogger;
|
||||||
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
|
QPointer<Chat::ChatOutputPane> m_chatOutputPane;
|
||||||
QPointer<Chat::NavigationPanel> m_navigationPanel;
|
QPointer<Chat::NavigationPanel> m_navigationPanel;
|
||||||
|
QPointer<Chat::SessionFileRegistry> m_sessionFileRegistry;
|
||||||
|
Chat::ChatEditorFactory *m_chatEditorFactory{nullptr};
|
||||||
|
QPointer<QMenu> m_chatButtonMenu;
|
||||||
QPointer<PluginUpdater> m_updater;
|
QPointer<PluginUpdater> m_updater;
|
||||||
UpdateStatusWidget *m_statusWidget{nullptr};
|
UpdateStatusWidget *m_statusWidget{nullptr};
|
||||||
QString m_lastRefactorInstructions;
|
QString m_lastRefactorInstructions;
|
||||||
|
|||||||
@@ -298,8 +298,6 @@ ChatAssistantSettings::ChatAssistantSettings()
|
|||||||
Column{
|
Column{
|
||||||
linkOpenFiles,
|
linkOpenFiles,
|
||||||
autosave,
|
autosave,
|
||||||
enableChatInBottomToolBar,
|
|
||||||
enableChatInNavigationPanel,
|
|
||||||
Row{autoCompress, autoCompressThreshold, Stretch{1}}}},
|
Row{autoCompress, autoCompressThreshold, Stretch{1}}}},
|
||||||
Space{8},
|
Space{8},
|
||||||
Group{
|
Group{
|
||||||
|
|||||||
Reference in New Issue
Block a user