소스 검색

support tags

Le Tan 4 년 전
부모
커밋
0a2bdc7033
100개의 변경된 파일3233개의 추가작업 그리고 564개의 파일을 삭제
  1. 5 0
      src/core/buffer/buffer.cpp
  2. 2 0
      src/core/buffer/buffer.h
  3. 2 0
      src/core/buffer/bufferprovider.h
  4. 5 0
      src/core/buffer/filebufferprovider.cpp
  5. 2 0
      src/core/buffer/filebufferprovider.h
  6. 6 0
      src/core/buffer/nodebufferprovider.cpp
  7. 2 0
      src/core/buffer/nodebufferprovider.h
  8. 1 0
      src/core/coreconfig.h
  9. 1 0
      src/core/editorconfig.h
  10. 3 0
      src/core/fileopenparameters.h
  11. 11 4
      src/core/historymgr.cpp
  12. 86 0
      src/core/notebook/bundlenotebook.cpp
  13. 29 4
      src/core/notebook/bundlenotebook.h
  14. 23 0
      src/core/notebook/historyi.h
  15. 11 0
      src/core/notebook/node.cpp
  16. 1 0
      src/core/notebook/node.h
  17. 10 0
      src/core/notebook/notebook.cpp
  18. 11 5
      src/core/notebook/notebook.h
  19. 6 0
      src/core/notebook/notebook.pri
  20. 400 16
      src/core/notebook/notebookdatabaseaccess.cpp
  21. 52 1
      src/core/notebook/notebookdatabaseaccess.h
  22. 318 0
      src/core/notebook/notebooktagmgr.cpp
  23. 78 0
      src/core/notebook/notebooktagmgr.h
  24. 47 0
      src/core/notebook/tag.cpp
  25. 37 0
      src/core/notebook/tag.h
  26. 37 0
      src/core/notebook/tagi.h
  27. 2 2
      src/core/notebookconfigmgr/bundlenotebookconfigmgr.cpp
  28. 28 39
      src/core/notebookconfigmgr/notebookconfig.cpp
  29. 5 17
      src/core/notebookconfigmgr/notebookconfig.h
  30. 2 0
      src/core/sessionconfig.cpp
  31. 4 1
      src/core/sessionconfig.h
  32. 12 1
      src/core/widgetconfig.cpp
  33. 6 0
      src/core/widgetconfig.h
  34. 5 2
      src/data/core/core.qrc
  35. 1 10
      src/data/core/icons/properties.svg
  36. 0 7
      src/data/core/icons/recycle_bin.svg
  37. 1 8
      src/data/core/icons/sort.svg
  38. 0 0
      src/data/core/icons/tag.svg
  39. 12 0
      src/data/core/icons/tag_dock.svg
  40. 12 0
      src/data/core/icons/tag_editor.svg
  41. 1 0
      src/data/core/icons/tag_selected.svg
  42. 0 0
      src/data/core/icons/up_level.svg
  43. 1 15
      src/data/core/icons/view.svg
  44. 5 2
      src/data/core/vnotex.json
  45. 4 4
      src/data/extra/docs/en/shortcuts.md
  46. 4 4
      src/data/extra/docs/zh_CN/shortcuts.md
  47. 1 1
      src/main.cpp
  48. 5 0
      src/search/searcher.cpp
  49. 3 6
      src/utils/pathutils.cpp
  50. 80 0
      src/widgets/dialogs/levellabelwithupbutton.cpp
  51. 52 0
      src/widgets/dialogs/levellabelwithupbutton.h
  52. 5 4
      src/widgets/dialogs/newfolderdialog.cpp
  53. 104 0
      src/widgets/dialogs/newtagdialog.cpp
  54. 42 0
      src/widgets/dialogs/newtagdialog.h
  55. 25 8
      src/widgets/dialogs/nodeinfowidget.cpp
  56. 2 2
      src/widgets/dialogs/nodeinfowidget.h
  57. 0 76
      src/widgets/dialogs/nodelabelwithupbutton.cpp
  58. 0 43
      src/widgets/dialogs/nodelabelwithupbutton.h
  59. 7 6
      src/widgets/dialogs/notepropertiesdialog.cpp
  60. 82 0
      src/widgets/dialogs/renametagdialog.cpp
  61. 37 0
      src/widgets/dialogs/renametagdialog.h
  62. 0 16
      src/widgets/dialogs/sortdialog.cpp
  63. 58 0
      src/widgets/dialogs/viewtagsdialog.cpp
  64. 35 0
      src/widgets/dialogs/viewtagsdialog.h
  65. 23 2
      src/widgets/dockwidgethelper.cpp
  66. 4 1
      src/widgets/dockwidgethelper.h
  67. 20 26
      src/widgets/historypanel.cpp
  68. 10 7
      src/widgets/historypanel.h
  69. 25 0
      src/widgets/listwidget.cpp
  70. 8 0
      src/widgets/listwidget.h
  71. 4 8
      src/widgets/locationlist.cpp
  72. 0 2
      src/widgets/locationlist.h
  73. 31 5
      src/widgets/mainwindow.cpp
  74. 8 1
      src/widgets/mainwindow.h
  75. 9 4
      src/widgets/markdownviewwindow.cpp
  76. 1 0
      src/widgets/navigationmodemgr.h
  77. 17 11
      src/widgets/notebookexplorer.cpp
  78. 0 3
      src/widgets/notebookexplorer.h
  79. 125 117
      src/widgets/notebooknodeexplorer.cpp
  80. 18 13
      src/widgets/notebooknodeexplorer.h
  81. 5 2
      src/widgets/outlinepopup.cpp
  82. 5 8
      src/widgets/quickselector.cpp
  83. 5 10
      src/widgets/snippetpanel.cpp
  84. 2 5
      src/widgets/snippetpanel.h
  85. 3 1
      src/widgets/styleditemdelegate.h
  86. 423 0
      src/widgets/tagexplorer.cpp
  87. 105 0
      src/widgets/tagexplorer.h
  88. 49 0
      src/widgets/tagpopup.cpp
  89. 34 0
      src/widgets/tagpopup.h
  90. 323 0
      src/widgets/tagviewer.cpp
  91. 79 0
      src/widgets/tagviewer.h
  92. 2 0
      src/widgets/textviewwindow.cpp
  93. 1 1
      src/widgets/toolbarhelper.cpp
  94. 7 22
      src/widgets/treewidget.cpp
  95. 4 4
      src/widgets/treewidget.h
  96. 3 1
      src/widgets/viewarea.cpp
  97. 22 0
      src/widgets/viewwindow.cpp
  98. 10 6
      src/widgets/viewwindow.h
  99. 18 0
      src/widgets/viewwindowtoolbarhelper.cpp
  100. 1 0
      src/widgets/viewwindowtoolbarhelper.h

+ 5 - 0
src/core/buffer/buffer.cpp

@@ -526,6 +526,11 @@ bool Buffer::isAttachment(const QString &p_path) const
     return PathUtils::pathContains(getAttachmentFolderPath(), p_path);
 }
 
+bool Buffer::isTagSupported() const
+{
+    return m_provider->isTagSupported();
+}
+
 Buffer::ProviderType Buffer::getProviderType() const
 {
     return m_provider->getType();

+ 2 - 0
src/core/buffer/buffer.h

@@ -165,6 +165,8 @@ namespace vnotex
         // Judge whether file @p_path is attachment.
         bool isAttachment(const QString &p_path) const;
 
+        bool isTagSupported() const;
+
         ProviderType getProviderType() const;
 
         bool checkFileExistsOnDisk();

+ 2 - 0
src/core/buffer/bufferprovider.h

@@ -68,6 +68,8 @@ namespace vnotex
 
         virtual bool isAttachmentSupported() const = 0;
 
+        virtual bool isTagSupported() const = 0;
+
         virtual bool checkFileExistsOnDisk() const;
 
         virtual bool checkFileChangedOutside() const;

+ 5 - 0
src/core/buffer/filebufferprovider.cpp

@@ -169,6 +169,11 @@ bool FileBufferProvider::isAttachmentSupported() const
     return false;
 }
 
+bool FileBufferProvider::isTagSupported() const
+{
+    return false;
+}
+
 Node *FileBufferProvider::getNode() const
 {
     return c_nodeAttachedTo;

+ 2 - 0
src/core/buffer/filebufferprovider.h

@@ -63,6 +63,8 @@ namespace vnotex
 
         bool isAttachmentSupported() const Q_DECL_OVERRIDE;
 
+        bool isTagSupported() const Q_DECL_OVERRIDE;
+
         bool isReadOnly() const Q_DECL_OVERRIDE;
 
         QSharedPointer<File> getFile() const Q_DECL_OVERRIDE;

+ 6 - 0
src/core/buffer/nodebufferprovider.cpp

@@ -3,6 +3,7 @@
 #include <QFileInfo>
 
 #include <notebook/node.h>
+#include <notebook/notebook.h>
 #include <utils/pathutils.h>
 #include <core/file.h>
 
@@ -148,6 +149,11 @@ bool NodeBufferProvider::isAttachmentSupported() const
     return true;
 }
 
+bool NodeBufferProvider::isTagSupported() const
+{
+    return m_node->getNotebook()->tag() != nullptr;
+}
+
 Node *NodeBufferProvider::getNode() const
 {
     return m_node.data();

+ 2 - 0
src/core/buffer/nodebufferprovider.h

@@ -65,6 +65,8 @@ namespace vnotex
 
         bool isAttachmentSupported() const Q_DECL_OVERRIDE;
 
+        bool isTagSupported() const Q_DECL_OVERRIDE;
+
         bool isReadOnly() const Q_DECL_OVERRIDE;
 
         QSharedPointer<File> getFile() const Q_DECL_OVERRIDE;

+ 1 - 0
src/core/coreconfig.h

@@ -29,6 +29,7 @@ namespace vnotex
             SnippetDock,
             LocationListDock,
             HistoryDock,
+            TagDock,
             Search,
             NavigationMode,
             LocateNode,

+ 1 - 0
src/core/editorconfig.h

@@ -58,6 +58,7 @@ namespace vnotex
             FindNext,
             FindPrevious,
             ApplySnippet,
+            Tag,
             MaxShortcut
         };
         Q_ENUM(Shortcut)

+ 3 - 0
src/core/fileopenparameters.h

@@ -38,6 +38,9 @@ namespace vnotex
 
         // If not empty, use this token to do a search text highlight.
         QSharedPointer<SearchToken> m_searchToken;
+
+        // Whether should save this file into session.
+        bool m_sessionEnabled = true;
     };
 }
 

+ 11 - 4
src/core/historymgr.cpp

@@ -8,6 +8,7 @@
 #include "vnotex.h"
 #include "notebookmgr.h"
 #include <notebook/notebook.h>
+#include <notebook/historyi.h>
 #include <notebookbackend/inotebookbackend.h>
 #include "exception.h"
 
@@ -57,7 +58,11 @@ void HistoryMgr::loadHistory()
     if (m_perNotebookHistoryEnabled) {
         const auto &notebooks = VNoteX::getInst().getNotebookMgr().getNotebooks();
         for (const auto &nb : notebooks) {
-            const auto &history = nb->getHistory();
+            auto historyI = nb->history();
+            if (!historyI) {
+                continue;
+            }
+            const auto &history = historyI->getHistory();
             const auto &backend = nb->getBackend();
             for (const auto &item : history) {
                 auto fullItem = QSharedPointer<HistoryItemFull>::create();
@@ -102,8 +107,8 @@ void HistoryMgr::add(const QString &p_path,
 
     HistoryItem item(p_path, p_lineNumber, QDateTime::currentDateTimeUtc());
 
-    if (p_notebook && m_perNotebookHistoryEnabled) {
-        p_notebook->addHistory(item);
+    if (p_notebook && m_perNotebookHistoryEnabled && p_notebook->history()) {
+        p_notebook->history()->addHistory(item);
     } else {
         auto &sessionConfig = ConfigMgr::getInst().getSessionConfig();
         sessionConfig.addHistory(item);
@@ -176,7 +181,9 @@ void HistoryMgr::clear()
     if (m_perNotebookHistoryEnabled) {
         const auto &notebooks = VNoteX::getInst().getNotebookMgr().getNotebooks();
         for (const auto &nb : notebooks) {
-            nb->clearHistory();
+            if (auto historyI = nb->history()) {
+                historyI->clearHistory();
+            }
         }
     }
 

+ 86 - 0
src/core/notebook/bundlenotebook.cpp

@@ -11,6 +11,7 @@
 #include <notebookbackend/inotebookbackend.h>
 
 #include "notebookdatabaseaccess.h"
+#include "notebooktagmgr.h"
 
 using namespace vnotex;
 
@@ -20,6 +21,7 @@ BundleNotebook::BundleNotebook(const NotebookParameters &p_paras,
     : Notebook(p_paras, p_parent),
       m_configVersion(p_notebookConfig->m_version),
       m_history(p_notebookConfig->m_history),
+      m_tagGraph(p_notebookConfig->m_tagGraph),
       m_extraConfigs(p_notebookConfig->m_extraConfigs)
 {
     setupDatabase();
@@ -59,6 +61,15 @@ void BundleNotebook::initDatabase()
         int cnt = 0;
         fillNodeTableFromConfig(getRootNode().data(), m_configVersion < 2, cnt);
         qDebug() << "fillNodeTableFromConfig nodes count" << cnt;
+
+        fillTagTableFromTagGraph();
+
+        cnt = 0;
+        fillTagTableFromConfig(getRootNode().data(), cnt);
+    }
+
+    if (m_tagMgr) {
+        m_tagMgr->update();
     }
 }
 
@@ -87,6 +98,11 @@ void BundleNotebook::remove()
     }
 }
 
+HistoryI *BundleNotebook::history()
+{
+    return this;
+}
+
 const QVector<HistoryItem> &BundleNotebook::getHistory() const
 {
     return m_history;
@@ -166,5 +182,75 @@ bool BundleNotebook::rebuildDatabase()
 
     setupDatabase();
     initDatabase();
+
+    emit tagsUpdated();
+
     return true;
 }
+
+const QString &BundleNotebook::getTagGraph() const
+{
+    return m_tagGraph;
+}
+
+void BundleNotebook::updateTagGraph(const QString &p_tagGraph)
+{
+    if (m_tagGraph == p_tagGraph) {
+        return;
+    }
+
+    m_tagGraph = p_tagGraph;
+    updateNotebookConfig();
+}
+
+void BundleNotebook::fillTagTableFromTagGraph()
+{
+    auto tagGraph = NotebookTagMgr::stringToTagGraph(m_tagGraph);
+    for (const auto &tagPair : tagGraph) {
+        if (!m_dbAccess->addTag(tagPair.m_parent)) {
+            qWarning() << "failed to add tag to DB" << tagPair.m_parent;
+            continue;
+        }
+
+        if (!m_dbAccess->addTag(tagPair.m_child, tagPair.m_parent)) {
+            qWarning() << "failed to add tag to DB" << tagPair.m_child;
+            continue;
+        }
+    }
+
+    QCoreApplication::processEvents();
+}
+
+void BundleNotebook::fillTagTableFromConfig(Node *p_node, int &p_totalCnt)
+{
+    // @p_node must already exists in node table.
+    bool ret = m_dbAccess->updateNodeTags(p_node);
+    if (!ret) {
+        qWarning() << "failed to add tags of node to DB" << p_node->getName() << p_node->getTags();
+        return;
+    }
+
+    if (++p_totalCnt % 10) {
+        QCoreApplication::processEvents();
+    }
+
+    const auto &children = p_node->getChildrenRef();
+    for (const auto &child : children) {
+        fillTagTableFromConfig(child.data(), p_totalCnt);
+    }
+}
+
+NotebookTagMgr *BundleNotebook::getTagMgr() const
+{
+    if (!m_tagMgr) {
+        auto th = const_cast<BundleNotebook *>(this);
+        th->m_tagMgr = new NotebookTagMgr(th);
+    }
+
+    return m_tagMgr;
+}
+
+TagI *BundleNotebook::tag()
+{
+    return getTagMgr();
+}

+ 29 - 4
src/core/notebook/bundlenotebook.h

@@ -3,14 +3,17 @@
 
 #include "notebook.h"
 #include "global.h"
+#include "historyi.h"
 
 namespace vnotex
 {
     class BundleNotebookConfigMgr;
     class NotebookConfig;
     class NotebookDatabaseAccess;
+    class NotebookTagMgr;
 
-    class BundleNotebook : public Notebook
+    class BundleNotebook : public Notebook,
+                           public HistoryI
     {
         Q_OBJECT
     public:
@@ -26,9 +29,8 @@ namespace vnotex
 
         void remove() Q_DECL_OVERRIDE;
 
-        const QVector<HistoryItem> &getHistory() const Q_DECL_OVERRIDE;
-        void addHistory(const HistoryItem &p_item) Q_DECL_OVERRIDE;
-        void clearHistory() Q_DECL_OVERRIDE;
+        const QString &getTagGraph() const;
+        void updateTagGraph(const QString &p_tagGraph);
 
         const QJsonObject &getExtraConfigs() const Q_DECL_OVERRIDE;
         void setExtraConfig(const QString &p_key, const QJsonObject &p_obj) Q_DECL_OVERRIDE;
@@ -37,6 +39,18 @@ namespace vnotex
 
         NotebookDatabaseAccess *getDatabaseAccess() const;
 
+        TagI *tag() Q_DECL_OVERRIDE;
+
+        // HistoryI.
+    public:
+        HistoryI *history() Q_DECL_OVERRIDE;
+
+        const QVector<HistoryItem> &getHistory() const Q_DECL_OVERRIDE;
+
+        void addHistory(const HistoryItem &p_item) Q_DECL_OVERRIDE;
+
+        void clearHistory() Q_DECL_OVERRIDE;
+
     protected:
         void initializeInternal() Q_DECL_OVERRIDE;
 
@@ -49,14 +63,25 @@ namespace vnotex
 
         void initDatabase();
 
+        void fillTagTableFromTagGraph();
+
+        void fillTagTableFromConfig(Node *p_node, int &p_totalCnt);
+
+        NotebookTagMgr *getTagMgr() const;
+
         const int m_configVersion;
 
         QVector<HistoryItem> m_history;
 
+        QString m_tagGraph;
+
         QJsonObject m_extraConfigs;
 
         // Managed by QObject.
         NotebookDatabaseAccess *m_dbAccess = nullptr;
+
+        // Managed by QObject.
+        NotebookTagMgr *m_tagMgr = nullptr;
     };
 } // ns vnotex
 

+ 23 - 0
src/core/notebook/historyi.h

@@ -0,0 +1,23 @@
+#ifndef HISTORYI_H
+#define HISTORYI_H
+
+#include <QVector>
+
+#include <core/historyitem.h>
+
+namespace vnotex
+{
+    // History interface for notebook.
+    class HistoryI
+    {
+    public:
+        virtual ~HistoryI() = default;
+
+        virtual const QVector<HistoryItem> &getHistory() const = 0;
+
+        virtual void addHistory(const HistoryItem &p_item) = 0;
+
+        virtual void clearHistory() = 0;
+    };
+}
+#endif // HISTORYI_H

+ 11 - 0
src/core/notebook/node.cpp

@@ -245,6 +245,17 @@ const QStringList &Node::getTags() const
     return m_tags;
 }
 
+void Node::updateTags(const QStringList &p_tags)
+{
+    if (p_tags == m_tags) {
+        return;
+    }
+
+    m_tags = p_tags;
+    save();
+    emit m_notebook->nodeUpdated(this);
+}
+
 bool Node::isReadOnly() const
 {
     return m_flags & Flag::ReadOnly;

+ 1 - 0
src/core/notebook/node.h

@@ -132,6 +132,7 @@ namespace vnotex
         virtual void save();
 
         const QStringList &getTags() const;
+        void updateTags(const QStringList &p_tags);
 
         const QString &getAttachmentFolder() const;
         void setAttachmentFolder(const QString &p_attachmentFolder);

+ 10 - 0
src/core/notebook/notebook.cpp

@@ -406,3 +406,13 @@ bool Notebook::rebuildDatabase()
 {
     return false;
 }
+
+HistoryI *Notebook::history()
+{
+    return nullptr;
+}
+
+TagI *Notebook::tag()
+{
+    return nullptr;
+}

+ 11 - 5
src/core/notebook/notebook.h

@@ -8,7 +8,6 @@
 #include "notebookparameters.h"
 #include <core/global.h>
 #include "node.h"
-#include <core/historyitem.h>
 
 namespace vnotex
 {
@@ -17,6 +16,8 @@ namespace vnotex
     class INotebookConfigMgr;
     class NodeParameters;
     class File;
+    class HistoryI;
+    class TagI;
 
     // Base class of notebook.
     class Notebook : public QObject
@@ -133,10 +134,6 @@ namespace vnotex
 
         void reloadNodes();
 
-        virtual const QVector<HistoryItem> &getHistory() const = 0;
-        virtual void addHistory(const HistoryItem &p_item) = 0;
-        virtual void clearHistory() = 0;
-
         // Hold extra 3rd party configs.
         virtual const QJsonObject &getExtraConfigs() const = 0;
         QJsonObject getExtraConfig(const QString &p_key) const;
@@ -153,11 +150,20 @@ namespace vnotex
 
         static const QString c_defaultImageFolder;
 
+    public:
+        // Return null if history is not suported.
+        virtual HistoryI *history();
+
+        // Return null if tag is not suported.
+        virtual TagI *tag();
+
     signals:
         void updated();
 
         void nodeUpdated(const Node *p_node);
 
+        void tagsUpdated();
+
     protected:
         virtual void initializeInternal() = 0;
 

+ 6 - 0
src/core/notebook/notebook.pri

@@ -7,11 +7,14 @@ SOURCES += \
     $$PWD/notebookparameters.cpp \
     $$PWD/bundlenotebook.cpp \
     $$PWD/node.cpp \
+    $$PWD/notebooktagmgr.cpp \
+    $$PWD/tag.cpp \
     $$PWD/vxnode.cpp \
     $$PWD/vxnodefile.cpp
 
 HEADERS += \
     $$PWD/externalnode.h \
+    $$PWD/historyi.h \
     $$PWD/nodeparameters.h \
     $$PWD/notebook.h \
     $$PWD/inotebookfactory.h \
@@ -20,5 +23,8 @@ HEADERS += \
     $$PWD/notebookparameters.h \
     $$PWD/bundlenotebook.h \
     $$PWD/node.h \
+    $$PWD/notebooktagmgr.h \
+    $$PWD/tag.h \
+    $$PWD/tagi.h \
     $$PWD/vxnode.h \
     $$PWD/vxnodefile.h

+ 400 - 16
src/core/notebook/notebookdatabaseaccess.cpp

@@ -2,6 +2,7 @@
 
 #include <QtSql>
 #include <QDebug>
+#include <QSet>
 
 #include <core/exception.h>
 
@@ -12,6 +13,10 @@ using namespace vnotex;
 
 static QString c_nodeTableName = "node";
 
+static QString c_tagTableName = "tag";
+
+static QString c_nodeTagTableName = "tag_node";
+
 NotebookDatabaseAccess::NotebookDatabaseAccess(Notebook *p_notebook, const QString &p_databaseFile, QObject *p_parent)
     : QObject(p_parent),
       m_notebook(p_notebook),
@@ -64,18 +69,40 @@ void NotebookDatabaseAccess::setupTables(QSqlDatabase &p_db, int p_configVersion
 
     QSqlQuery query(p_db);
 
-    // Node.
     if (m_fresh) {
+        // Node.
         bool ret = query.exec(QString("CREATE TABLE %1 (\n"
                                       "    id INTEGER PRIMARY KEY,\n"
-                                      "    name text NOT NULL,\n"
+                                      "    name TEXT NOT NULL,\n"
                                       "    signature INTEGER NOT NULL,\n"
-                                      "    parent_id INTEGER NULL REFERENCES %1(id) ON DELETE CASCADE)\n").arg(c_nodeTableName));
+                                      "    parent_id INTEGER NULL REFERENCES %1(id) ON DELETE CASCADE ON UPDATE CASCADE)\n").arg(c_nodeTableName));
         if (!ret) {
             qWarning() << QString("failed to create database table (%1) (%2)").arg(c_nodeTableName, query.lastError().text());
             m_valid = false;
             return;
         }
+
+        // Tag.
+        ret = query.exec(QString("CREATE TABLE %1 (\n"
+                                 "    name TEXT PRIMARY KEY,\n"
+                                 "    parent_name TEXT NULL REFERENCES %1(name) ON DELETE CASCADE ON UPDATE CASCADE) WITHOUT ROWID\n").arg(c_tagTableName));
+        if (!ret) {
+            qWarning() << QString("failed to create database table (%1) (%2)").arg(c_tagTableName, query.lastError().text());
+            m_valid = false;
+            return;
+        }
+
+        // Node_Tag.
+        ret = query.exec(QString("CREATE TABLE %1 (\n"
+                                 "    node_id INTEGER REFERENCES %2(id) ON DELETE CASCADE ON UPDATE CASCADE,\n"
+                                 "    tag_name TEXT REFERENCES %3(name) ON DELETE CASCADE ON UPDATE CASCADE)\n").arg(c_nodeTagTableName,
+                                                                                                                     c_nodeTableName,
+                                                                                                                     c_tagTableName));
+        if (!ret) {
+            qWarning() << QString("failed to create database table (%1) (%2)").arg(c_nodeTagTableName, query.lastError().text());
+            m_valid = false;
+            return;
+        }
     }
 }
 
@@ -113,7 +140,7 @@ bool NotebookDatabaseAccess::addNode(Node *p_node, bool p_ignoreId)
         if (p_node->getId() != InvalidId) {
             auto nodeRec = queryNode(p_node->getId());
             if (nodeRec) {
-                auto nodePath = queryNodePath(p_node->getId());
+                auto nodePath = queryNodeParentPath(p_node->getId());
                 if (existsNode(p_node, nodeRec.data(), nodePath)) {
                     return true;
                 }
@@ -156,7 +183,7 @@ bool NotebookDatabaseAccess::addNode(Node *p_node, bool p_ignoreId)
     }
 
     if (!query.exec()) {
-        qWarning() << "failed to add node by query" << query.executedQuery() << query.lastError().text();
+        qWarning() << "failed to add node" << query.executedQuery() << query.lastError().text();
         return false;
     }
 
@@ -164,13 +191,7 @@ bool NotebookDatabaseAccess::addNode(Node *p_node, bool p_ignoreId)
     const ID preId = p_node->getId();
     p_node->updateId(id);
 
-    qDebug("added node id %llu preId %llu ignoreId %d sig %llu name %s parentId %llu",
-           id,
-           preId,
-           p_ignoreId,
-           p_node->getSignature(),
-           p_node->getName().toStdString(),
-           p_node->getParent() ? p_node->getParent()->getId() : Node::InvalidId);
+    qDebug() << "added node id" << id << p_node->getName();
     return true;
 }
 
@@ -178,7 +199,7 @@ QSharedPointer<NotebookDatabaseAccess::NodeRecord> NotebookDatabaseAccess::query
 {
     auto db = getDatabase();
     QSqlQuery query(db);
-    query.prepare(QString("SELECT id, name, signature, parent_id from %1 where id = :id").arg(c_nodeTableName));
+    query.prepare(QString("SELECT id, name, signature, parent_id FROM %1 WHERE id = :id").arg(c_nodeTableName));
     query.bindValue(":id", p_id);
     if (!query.exec()) {
         qWarning() << "failed to query node" << query.executedQuery() << query.lastError().text();
@@ -210,7 +231,7 @@ bool NotebookDatabaseAccess::existsNode(const Node *p_node)
 
     return existsNode(p_node,
                       queryNode(p_node->getId()).data(),
-                      queryNodePath(p_node->getId()));
+                      queryNodeParentPath(p_node->getId()));
 }
 
 bool NotebookDatabaseAccess::existsNode(const Node *p_node, const NodeRecord *p_rec, const QStringList &p_nodePath)
@@ -226,7 +247,7 @@ bool NotebookDatabaseAccess::existsNode(const Node *p_node, const NodeRecord *p_
     return checkNodePath(p_node, p_nodePath);
 }
 
-QStringList NotebookDatabaseAccess::queryNodePath(ID p_id)
+QStringList NotebookDatabaseAccess::queryNodeParentPath(ID p_id)
 {
     auto db = getDatabase();
     QSqlQuery query(db);
@@ -239,7 +260,7 @@ QStringList NotebookDatabaseAccess::queryNodePath(ID p_id)
                           "    FROM %1 node\n"
                           "    JOIN cte_parents cte ON node.id = cte.parent_id\n"
                           "    LIMIT 5000)\n"
-                          "SELECT * FROM cte_parents").arg(c_nodeTableName));
+                          "SELECT id, name, parent_id FROM cte_parents").arg(c_nodeTableName));
     query.bindValue(":id", p_id);
     if (!query.exec()) {
         qWarning() << "failed to query node's path" << query.executedQuery() << query.lastError().text();
@@ -259,6 +280,22 @@ QStringList NotebookDatabaseAccess::queryNodePath(ID p_id)
     return ret;
 }
 
+QString NotebookDatabaseAccess::queryNodePath(ID p_id)
+{
+    auto parentPath = queryNodeParentPath(p_id);
+    if (parentPath.isEmpty()) {
+        return QString();
+    }
+
+    if (parentPath.size() == 1) {
+        return parentPath.first();
+    }
+
+    QString relativePath = parentPath.join(QLatin1Char('/'));
+    Q_ASSERT(relativePath[0] == QLatin1Char('/'));
+    return relativePath.mid(1);
+}
+
 bool NotebookDatabaseAccess::updateNode(const Node *p_node)
 {
     Q_ASSERT(p_node->getParent());
@@ -379,3 +416,350 @@ bool NotebookDatabaseAccess::checkNodePath(const Node *p_node, const QStringList
 
     return true;
 }
+
+bool NotebookDatabaseAccess::addTag(const QString &p_name, const QString &p_parentName)
+{
+    return addTag(p_name, p_parentName, true);
+}
+
+bool NotebookDatabaseAccess::addTag(const QString &p_name)
+{
+    return addTag(p_name, QString(), false);
+}
+
+bool NotebookDatabaseAccess::addTag(const QString &p_name, const QString &p_parentName, bool p_updateOnExists)
+{
+    {
+        auto tagRec = queryTag(p_name);
+        if (tagRec) {
+            if (!p_updateOnExists || tagRec->m_parentName == p_parentName) {
+                return true;
+            }
+
+            return updateTagParent(p_name, p_parentName);
+        }
+    }
+
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("INSERT INTO %1 (name, parent_name)\n"
+                          "    VALUES (:name, :parent_name)").arg(c_tagTableName));
+    query.bindValue(":name", p_name);
+    query.bindValue(":parent_name", p_parentName.isEmpty() ? QVariant() : p_parentName);
+
+    if (!query.exec()) {
+        qWarning() << "failed to add tag" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+
+    qDebug() << "added tag" << p_name << "parentName" << p_parentName;
+    return true;
+}
+
+QSharedPointer<NotebookDatabaseAccess::TagRecord> NotebookDatabaseAccess::queryTag(const QString &p_name)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("SELECT name, parent_name FROM %1 WHERE name = :name").arg(c_tagTableName));
+    query.bindValue(":name", p_name);
+    if (!query.exec()) {
+        qWarning() << "failed to query tag" << query.executedQuery() << query.lastError().text();
+        return nullptr;
+    }
+
+    if (query.next()) {
+        auto tagRec = QSharedPointer<TagRecord>::create();
+        tagRec->m_name = query.value(0).toString();
+        tagRec->m_parentName = query.value(1).toString();
+        return tagRec;
+    }
+
+    return nullptr;
+}
+
+bool NotebookDatabaseAccess::updateTagParent(const QString &p_name, const QString &p_parentName)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("UPDATE %1\n"
+                          "SET parent_name = :parent_name\n"
+                          "WHERE name = :name").arg(c_tagTableName));
+    query.bindValue(":name", p_name);
+    query.bindValue(":parent_name", p_parentName.isEmpty() ? QVariant() : p_parentName);
+    if (!query.exec()) {
+        qWarning() << "failed to update tag" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+
+    qDebug() << "updated tag parent" << p_name << p_parentName;
+
+    return true;
+}
+
+bool NotebookDatabaseAccess::renameTag(const QString &p_name, const QString &p_newName)
+{
+    Q_ASSERT(!p_newName.isEmpty());
+    if (p_name == p_newName) {
+        return true;
+    }
+
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("UPDATE %1\n"
+                          "SET name = :new_name\n"
+                          "WHERE name = :name").arg(c_tagTableName));
+    query.bindValue(":name", p_name);
+    query.bindValue(":new_name", p_newName);
+    if (!query.exec()) {
+        qWarning() << "failed to update tag" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+
+    qDebug() << "updated tag name" << p_name << p_newName;
+
+    return true;
+}
+
+bool NotebookDatabaseAccess::removeTag(const QString &p_name)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("DELETE FROM %1\n"
+                          "WHERE name = :name").arg(c_tagTableName));
+    query.bindValue(":name", p_name);
+    if (!query.exec()) {
+        qWarning() << "failed to remove tag" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+    qDebug() << "removed tag" << p_name;
+    return true;
+}
+
+bool NotebookDatabaseAccess::updateNodeTags(Node *p_node)
+{
+    p_node->load();
+
+    if (p_node->getId() == Node::InvalidId) {
+        qWarning() << "failed to update tags of node with invalid id" << p_node->fetchPath();
+        return false;
+    }
+
+    const auto &nodeTags = p_node->getTags();
+
+    {
+        const auto tags = QSet<QString>::fromList(queryNodeTags(p_node->getId()));
+        if (tags.isEmpty() && nodeTags.isEmpty()) {
+            return true;
+        }
+
+        bool needUpdate = false;
+        if (tags.size() != nodeTags.size()) {
+            needUpdate = true;
+        }
+
+        for (const auto &tag : nodeTags) {
+            if (tags.find(tag) == tags.end()) {
+                needUpdate = true;
+
+                if (!addTag(tag)) {
+                    qWarning() << "failed to add tag before addNodeTags" << p_node->getId() << tag;
+                    return false;
+                }
+            }
+        }
+
+        if (!needUpdate) {
+            return true;
+        }
+    }
+
+    bool ret = removeNodeTags(p_node->getId());
+    if (!ret) {
+        return false;
+    }
+
+    return addNodeTags(p_node->getId(), nodeTags);
+}
+
+QStringList NotebookDatabaseAccess::queryNodeTags(ID p_id)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("SELECT tag_name FROM %1 WHERE node_id = :node_id").arg(c_nodeTagTableName));
+    query.bindValue(":node_id", p_id);
+    if (!query.exec()) {
+        qWarning() << "failed to query node's tags" << query.executedQuery() << query.lastError().text();
+        return QStringList();
+    }
+
+    QStringList tags;
+    while (query.next()) {
+        tags.append(query.value(0).toString());
+    }
+    return tags;
+}
+
+bool NotebookDatabaseAccess::removeNodeTags(ID p_id)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("DELETE FROM %1\n"
+                          "WHERE node_id = :node_id").arg(c_nodeTagTableName));
+    query.bindValue(":node_id", p_id);
+    if (!query.exec()) {
+        qWarning() << "failed to remove tags of node" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+    qDebug() << "removed tags of node" << p_id;
+    return true;
+}
+
+bool NotebookDatabaseAccess::addNodeTags(ID p_id, const QStringList &p_tags)
+{
+    Q_ASSERT(p_id != Node::InvalidId);
+    if (p_tags.isEmpty()) {
+        return true;
+    }
+
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("INSERT INTO %1 (node_id, tag_name)\n"
+                          "    VALUES (?, ?)").arg(c_nodeTagTableName));
+
+    QVariantList ids;
+    QVariantList tagNames;
+    for (const auto &tag : p_tags) {
+        ids << p_id;
+        tagNames << tag;
+    }
+
+    query.addBindValue(ids);
+    query.addBindValue(tagNames);
+
+    if (!query.execBatch()) {
+        qWarning() << "failed to add tags of node" << query.executedQuery() << query.lastError().text();
+        return false;
+    }
+
+    qDebug() << "added tags of node" << p_id << p_tags;
+    return true;
+}
+
+QList<ID> NotebookDatabaseAccess::queryTagNodes(const QString &p_tag)
+{
+    QList<ID> nodes;
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("SELECT node_id FROM %1 WHERE tag_name = :tag_name").arg(c_nodeTagTableName));
+    query.bindValue(":tag_name", p_tag);
+    if (!query.exec()) {
+        qWarning() << "failed to query nodes of tag" << query.executedQuery() << query.lastError().text();
+        return nodes;
+    }
+
+    while (query.next()) {
+        nodes.append(query.value(0).toULongLong());
+    }
+    return nodes;
+}
+
+QList<ID> NotebookDatabaseAccess::queryTagNodesRecursive(const QString &p_tag)
+{
+    auto tags = queryTagAndChildren(p_tag);
+    if (tags.size() <= 1) {
+        return queryTagNodes(p_tag);
+    }
+
+    QSet<ID> allIds;
+    for (const auto &tag : tags) {
+        auto ids = queryTagNodes(tag);
+        for (const auto &id : ids) {
+            allIds.insert(id);
+        }
+    }
+
+    return allIds.toList();
+}
+
+QStringList NotebookDatabaseAccess::queryTagAndChildren(const QString &p_tag)
+{
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("WITH RECURSIVE cte_children(name, parent_name) AS (\n"
+                          "    SELECT tag.name, tag.parent_name\n"
+                          "    FROM %1 tag\n"
+                          "    WHERE tag.name = :name\n"
+                          "    UNION ALL\n"
+                          "    SELECT tag.name, tag.parent_name\n"
+                          "    FROM %1 tag\n"
+                          "    JOIN cte_children cte ON tag.parent_name = cte.name\n"
+                          "    LIMIT 5000)\n"
+                          "SELECT name FROM cte_children").arg(c_tagTableName));
+    query.bindValue(":name", p_tag);
+    if (!query.exec()) {
+        qWarning() << "failed to query tag and its children" << query.executedQuery() << query.lastError().text();
+        return QStringList();
+    }
+
+    QStringList ret;
+    while (query.next()) {
+        ret.append(query.value(0).toString());
+    }
+
+    qDebug() << "tag and its children" << p_tag << ret;
+    return ret;
+}
+
+QStringList NotebookDatabaseAccess::getNodesOfTags(const QStringList &p_tags)
+{
+    QStringList ret;
+    if (p_tags.isEmpty()) {
+        return ret;
+    }
+
+    QList<ID> nodeIds;
+
+    if (p_tags.size() == 1) {
+        nodeIds = queryTagNodesRecursive(p_tags.first());
+    } else {
+        QSet<ID> allIds;
+        for (const auto &tag : p_tags) {
+            auto ids = queryTagNodesRecursive(tag);
+            for (const auto &id : ids) {
+                allIds.insert(id);
+            }
+        }
+        nodeIds = allIds.toList();
+    }
+
+    for (const auto &id : nodeIds) {
+        auto nodePath = queryNodePath(id);
+        if (nodePath.isNull()) {
+            continue;
+        }
+
+        ret.append(nodePath);
+    }
+
+    return ret;
+}
+
+QList<NotebookDatabaseAccess::TagRecord> NotebookDatabaseAccess::getAllTags()
+{
+    QList<TagRecord> ret;
+
+    auto db = getDatabase();
+    QSqlQuery query(db);
+    query.prepare(QString("SELECT name, parent_name FROM %1 ORDER BY parent_name, name").arg(c_tagTableName));
+    if (!query.exec()) {
+        qWarning() << "failed to query tags" << query.executedQuery() << query.lastError().text();
+        return ret;
+    }
+
+    while (query.next()) {
+        ret.append(TagRecord());
+        ret.last().m_name = query.value(0).toString();
+        ret.last().m_parentName = query.value(1).toString();
+    }
+    return ret;
+}

+ 52 - 1
src/core/notebook/notebookdatabaseaccess.h

@@ -24,6 +24,13 @@ namespace vnotex
     public:
         enum { InvalidId = 0 };
 
+        struct TagRecord
+        {
+            QString m_name;
+
+            QString m_parentName;
+        };
+
         friend class tests::TestNotebookDatabase;
 
         NotebookDatabaseAccess(Notebook *p_notebook, const QString &p_databaseFile, QObject *p_parent = nullptr);
@@ -38,6 +45,8 @@ namespace vnotex
 
         void close();
 
+        // Node table.
+    public:
         bool addNode(Node *p_node, bool p_ignoreId);
 
         // Whether there is a record with the same ID in DB and has the same path.
@@ -49,6 +58,29 @@ namespace vnotex
 
         bool removeNode(const Node *p_node);
 
+        // Tag table.
+    public:
+        // Will update the tag if exists.
+        bool addTag(const QString &p_name, const QString &p_parentName);
+
+        bool addTag(const QString &p_name);
+
+        bool renameTag(const QString &p_name, const QString &p_newName);
+
+        bool removeTag(const QString &p_name);
+
+        // Sorted by parent_name.
+        QList<TagRecord> getAllTags();
+
+        QStringList queryTagAndChildren(const QString &p_tag);
+
+        // Node_tag table.
+    public:
+        bool updateNodeTags(Node *p_node);
+
+        // Return the relative path of nodes of tags @p_tags.
+        QStringList getNodesOfTags(const QStringList &p_tags);
+
     private:
         struct NodeRecord
         {
@@ -68,7 +100,9 @@ namespace vnotex
         // Return null if not exists.
         QSharedPointer<NodeRecord> queryNode(ID p_id);
 
-        QStringList queryNodePath(ID p_id);
+        QStringList queryNodeParentPath(ID p_id);
+
+        QString queryNodePath(ID p_id);
 
         bool nodeEqual(const NodeRecord *p_rec, const Node *p_node) const;
 
@@ -78,6 +112,23 @@ namespace vnotex
 
         bool removeNode(ID p_id);
 
+        // Return null if not exists.
+        QSharedPointer<TagRecord> queryTag(const QString &p_name);
+
+        bool updateTagParent(const QString &p_name, const QString &p_parentName);
+
+        bool addTag(const QString &p_name, const QString &p_parentName, bool p_updateOnExists);
+
+        QStringList queryNodeTags(ID p_id);
+
+        QList<ID> queryTagNodes(const QString &p_tag);
+
+        QList<ID> queryTagNodesRecursive(const QString &p_tag);
+
+        bool removeNodeTags(ID p_id);
+
+        bool addNodeTags(ID p_id, const QStringList &p_tags);
+
         Notebook *m_notebook = nullptr;
 
         QString m_databaseFile;

+ 318 - 0
src/core/notebook/notebooktagmgr.cpp

@@ -0,0 +1,318 @@
+#include "notebooktagmgr.h"
+
+#include <QDebug>
+#include <QHash>
+
+#include "bundlenotebook.h"
+#include "tag.h"
+
+using namespace vnotex;
+
+NotebookTagMgr::NotebookTagMgr(BundleNotebook *p_notebook)
+    : QObject(p_notebook),
+      m_notebook(p_notebook)
+{
+    update();
+}
+
+QVector<NotebookTagMgr::TagGraphPair> NotebookTagMgr::stringToTagGraph(const QString &p_text)
+{
+    // parent>chlid;parent2>chlid2.
+    QVector<TagGraphPair> tagGraph;
+    auto pairs = p_text.split(QLatin1Char(';'));
+    for (const auto &pa : pairs) {
+        auto paCh = pa.split(QLatin1Char('>'));
+        if (paCh.size() != 2 || paCh[0].isEmpty() || paCh[1].isEmpty()) {
+            qWarning() << "ignore invalid <parent, child> tag pair" << pa;
+            continue;
+        }
+
+        TagGraphPair tagPair;
+        tagPair.m_parent = paCh[0];
+        tagPair.m_child = paCh[1];
+        tagGraph.push_back(tagPair);
+    }
+
+    return tagGraph;
+}
+
+QString NotebookTagMgr::tagGraphToString(const QVector<TagGraphPair> &p_tagGraph)
+{
+    QString text;
+    if (p_tagGraph.isEmpty()) {
+        return text;
+    }
+
+    text = p_tagGraph[0].m_parent + QLatin1Char('>') + p_tagGraph[0].m_child;
+    for (int i = 1; i < p_tagGraph.size(); ++i) {
+        text += QLatin1Char(';') + p_tagGraph[i].m_parent + QLatin1Char('>') + p_tagGraph[i].m_child;
+    }
+
+    return text;
+}
+
+const QVector<QSharedPointer<Tag>> &NotebookTagMgr::getTopLevelTags() const
+{
+    return m_topLevelTags;
+}
+
+void NotebookTagMgr::update()
+{
+    auto db = m_notebook->getDatabaseAccess();
+    const auto allTags = db->getAllTags();
+
+    update(allTags);
+}
+
+void NotebookTagMgr::update(const QList<NotebookDatabaseAccess::TagRecord> &p_allTags)
+{
+    m_topLevelTags.clear();
+
+    QHash<QString, Tag *> nameToTag;
+
+    QVector<int> todoIdx;
+    todoIdx.reserve(p_allTags.size());
+    for (int i = 0; i < p_allTags.size(); ++i) {
+        todoIdx.push_back(i);
+    }
+
+    while (!todoIdx.isEmpty()) {
+        QVector<int> pendingIdx;
+        pendingIdx.reserve(p_allTags.size());
+
+        for (int i = 0; i < todoIdx.size(); ++i) {
+            const auto &rec = p_allTags[todoIdx[i]];
+            Q_ASSERT(!nameToTag.contains(rec.m_name));
+            QSharedPointer<Tag> newTag;
+            if (rec.m_parentName.isEmpty()) {
+                // Top level.
+                newTag = QSharedPointer<Tag>::create(rec.m_name);
+                m_topLevelTags.push_back(newTag);
+            } else {
+                auto parentIt = nameToTag.find(rec.m_parentName);
+                if (parentIt == nameToTag.end()) {
+                    // Need to process its parent first.
+                    pendingIdx.push_back(todoIdx[i]);
+                    continue;
+                } else {
+                    newTag = QSharedPointer<Tag>::create(rec.m_name);
+                    parentIt.value()->addChild(newTag);
+                }
+            }
+
+            nameToTag.insert(newTag->name(), newTag.data());
+        }
+
+        if (todoIdx.size() == pendingIdx.size()) {
+            qWarning() << "cyclic parent-chlid tag definition detected";
+            break;
+        }
+
+        todoIdx = pendingIdx;
+    }
+}
+
+QStringList NotebookTagMgr::findNodesOfTag(const QString &p_name)
+{
+    auto db = m_notebook->getDatabaseAccess();
+    return db->getNodesOfTags(QStringList(p_name));
+}
+
+QSharedPointer<Tag> NotebookTagMgr::findTag(const QString &p_name)
+{
+    QSharedPointer<Tag> tag;
+    forEachTag([&tag, p_name](const QSharedPointer<Tag> &p_tag) {
+                if (p_tag->name() == p_name) {
+                    tag = p_tag;
+                    return false;
+                }
+                return true;
+            });
+
+    return tag;
+}
+
+void NotebookTagMgr::forEachTag(const TagFinder &p_func) const
+{
+    for (const auto &tag : m_topLevelTags) {
+        if (!forEachTag(tag, p_func)) {
+            return;
+        }
+    }
+}
+
+bool NotebookTagMgr::forEachTag(const QSharedPointer<Tag> &p_tag, const TagFinder &p_func) const
+{
+    if (!p_func(p_tag)) {
+        return false;
+    }
+
+    for (const auto &child : p_tag->getChildren()) {
+        if (!forEachTag(child, p_func)) {
+            return false;
+        }
+    }
+
+    return true;
+}
+
+bool NotebookTagMgr::newTag(const QString &p_name, const QString &p_parentName)
+{
+    if (p_name.isEmpty()) {
+        return false;
+    }
+
+    auto db = m_notebook->getDatabaseAccess();
+    bool ret = db->addTag(p_name, p_parentName);
+    if (ret) {
+        const auto allTags = db->getAllTags();
+        update(allTags);
+        if (!p_parentName.isEmpty()) {
+            updateNotebookTagGraph(allTags);
+        }
+        emit m_notebook->tagsUpdated();
+        return true;
+    } else {
+        qWarning() << "failed to new tag" << p_name << p_parentName;
+        return false;
+    }
+}
+
+bool NotebookTagMgr::updateNodeTags(Node *p_node)
+{
+    auto db = m_notebook->getDatabaseAccess();
+
+    // Make sure the node exists in DB.
+    if (!db->addNode(p_node, false)) {
+        qWarning() << "failed to add node to DB" << p_node->fetchPath() << p_node->getId();
+        return false;
+    }
+
+    if (db->updateNodeTags(p_node)) {
+        update();
+        emit m_notebook->tagsUpdated();
+        return true;
+    }
+
+    return false;
+}
+
+bool NotebookTagMgr::updateNodeTags(Node *p_node, const QStringList &p_newTags)
+{
+    p_node->updateTags(p_newTags);
+    return updateNodeTags(p_node);
+}
+
+bool NotebookTagMgr::renameTag(const QString &p_name, const QString &p_newName)
+{
+    const auto nodePaths = findNodesOfTag(p_name);
+
+    auto db = m_notebook->getDatabaseAccess();
+    if (!db->renameTag(p_name, p_newName)) {
+        return false;
+    }
+
+    const auto allTags = db->getAllTags();
+    update(allTags);
+
+    updateNotebookTagGraph(allTags);
+
+    // Update node tag.
+    for (const auto &pa : nodePaths) {
+        auto node = m_notebook->loadNodeByPath(pa);
+        if (!node) {
+            qWarning() << "node belongs to tag in DB but not exists" << p_name << pa;
+            continue;
+        }
+
+        auto tags = node->getTags();
+        for (auto &tag : tags) {
+            if (tag == p_name) {
+                tag = p_newName;
+                break;
+            }
+        }
+        node->updateTags(tags);
+    }
+
+    emit m_notebook->tagsUpdated();
+    return true;
+}
+
+void NotebookTagMgr::updateNotebookTagGraph(const QList<NotebookDatabaseAccess::TagRecord> &p_allTags)
+{
+    QVector<TagGraphPair> graph;
+    graph.reserve(p_allTags.size());
+    for (const auto &tag : p_allTags) {
+        if (tag.m_parentName.isEmpty()) {
+            continue;
+        }
+        TagGraphPair pa;
+        pa.m_parent = tag.m_parentName;
+        pa.m_child = tag.m_name;
+        graph.push_back(pa);
+    }
+    m_notebook->updateTagGraph(tagGraphToString(graph));
+}
+
+bool NotebookTagMgr::removeTag(const QString &p_name)
+{
+    const auto nodePaths = findNodesOfTag(p_name);
+
+    auto db = m_notebook->getDatabaseAccess();
+    QStringList tagsAndChildren;
+    if (!nodePaths.isEmpty()) {
+        tagsAndChildren = db->queryTagAndChildren(p_name);
+        if (tagsAndChildren.isEmpty()) {
+            qWarning() << "failed to query tag and its children" << p_name;
+            return false;
+        }
+    }
+
+    if (!db->removeTag(p_name)) {
+        return false;
+    }
+
+    const auto allTags = db->getAllTags();
+    update(allTags);
+
+    updateNotebookTagGraph(allTags);
+
+    // Update node tag.
+    for (const auto &pa : nodePaths) {
+        auto node = m_notebook->loadNodeByPath(pa);
+        if (!node) {
+            qWarning() << "node belongs to tag in DB but not exists" << p_name << pa;
+            continue;
+        }
+
+        const auto &tags = node->getTags();
+        QStringList newTags;
+        for (const auto &tag : tags) {
+            if (tagsAndChildren.contains(tag)) {
+                continue;
+            }
+            newTags.append(tag);
+        }
+        node->updateTags(newTags);
+    }
+
+    emit m_notebook->tagsUpdated();
+    return true;
+}
+
+bool NotebookTagMgr::moveTag(const QString &p_name, const QString &p_newParentName)
+{
+    auto db = m_notebook->getDatabaseAccess();
+    if (!db->addTag(p_name, p_newParentName)) {
+        return false;
+    }
+
+    const auto allTags = db->getAllTags();
+    update(allTags);
+
+    updateNotebookTagGraph(allTags);
+
+    emit m_notebook->tagsUpdated();
+    return true;
+}

+ 78 - 0
src/core/notebook/notebooktagmgr.h

@@ -0,0 +1,78 @@
+#ifndef NOTEBOOKTAGMGR_H
+#define NOTEBOOKTAGMGR_H
+
+#include <QObject>
+
+#include "tagi.h"
+
+#include <functional>
+
+#include <QVector>
+#include <QSharedPointer>
+
+#include "notebookdatabaseaccess.h"
+
+namespace vnotex
+{
+    class BundleNotebook;
+    class Tag;
+
+    class NotebookTagMgr : public QObject, public TagI
+    {
+        Q_OBJECT
+    public:
+        struct TagGraphPair
+        {
+            QString m_parent;
+
+            QString m_child;
+        };
+
+        explicit NotebookTagMgr(BundleNotebook *p_notebook);
+
+        void update();
+
+        static QVector<TagGraphPair> stringToTagGraph(const QString &p_text);
+
+        static QString tagGraphToString(const QVector<TagGraphPair> &p_tagGraph);
+
+        // TagI.
+    public:
+        const QVector<QSharedPointer<Tag>> &getTopLevelTags() const Q_DECL_OVERRIDE;
+
+        QStringList findNodesOfTag(const QString &p_name) Q_DECL_OVERRIDE;
+
+        QSharedPointer<Tag> findTag(const QString &p_name) Q_DECL_OVERRIDE;
+
+        bool newTag(const QString &p_name, const QString &p_parentName) Q_DECL_OVERRIDE;
+
+        bool renameTag(const QString &p_name, const QString &p_newName) Q_DECL_OVERRIDE;
+
+        bool updateNodeTags(Node *p_node) Q_DECL_OVERRIDE;
+
+        bool updateNodeTags(Node *p_node, const QStringList &p_newTags) Q_DECL_OVERRIDE;
+
+        bool removeTag(const QString &p_name) Q_DECL_OVERRIDE;
+
+        bool moveTag(const QString &p_name, const QString &p_newParentName) Q_DECL_OVERRIDE;
+
+    private:
+        typedef std::function<bool(const QSharedPointer<Tag> &p_tag)> TagFinder;
+
+        // @p_func: return false to abort the search.
+        void forEachTag(const TagFinder &p_func) const;
+
+        // Return false if abort.
+        bool forEachTag(const QSharedPointer<Tag> &p_tag, const TagFinder &p_func) const;
+
+        void update(const QList<NotebookDatabaseAccess::TagRecord> &p_allTags);
+
+        void updateNotebookTagGraph(const QList<NotebookDatabaseAccess::TagRecord> &p_allTags);
+
+        BundleNotebook *m_notebook = nullptr;
+
+        QVector<QSharedPointer<Tag>> m_topLevelTags;
+    };
+}
+
+#endif // NOTEBOOKTAGMGR_H

+ 47 - 0
src/core/notebook/tag.cpp

@@ -0,0 +1,47 @@
+#include "tag.h"
+
+#include <QRegularExpression>
+
+#include <utils/pathutils.h>
+
+using namespace vnotex;
+
+Tag::Tag(const QString &p_name)
+    : m_name(p_name)
+{
+}
+
+const QVector<QSharedPointer<Tag>> &Tag::getChildren() const
+{
+    return m_children;
+}
+
+void Tag::addChild(const QSharedPointer<Tag> &p_tag)
+{
+    p_tag->m_parent = this;
+    m_children.push_back(p_tag);
+}
+
+const QString &Tag::name() const
+{
+    return m_name;
+}
+
+Tag *Tag::getParent() const
+{
+    return m_parent;
+}
+
+QString Tag::fetchPath() const
+{
+    if (!m_parent) {
+        return m_name;
+    } else {
+        return PathUtils::concatenateFilePath(m_parent->fetchPath(), m_name);
+    }
+}
+
+bool Tag::isValidName(const QString &p_name)
+{
+    return !p_name.isEmpty() && !p_name.contains(QRegularExpression("[>/]"));
+}

+ 37 - 0
src/core/notebook/tag.h

@@ -0,0 +1,37 @@
+#ifndef TAG_H
+#define TAG_H
+
+#include <QString>
+#include <QVector>
+#include <QSharedPointer>
+#include <QEnableSharedFromThis>
+
+namespace vnotex
+{
+    class Tag : public QEnableSharedFromThis<Tag>
+    {
+    public:
+        Tag(const QString &p_name);
+
+        const QVector<QSharedPointer<Tag>> &getChildren() const;
+
+        const QString &name() const;
+
+        Tag *getParent() const;
+
+        void addChild(const QSharedPointer<Tag> &p_tag);
+
+        QString fetchPath() const;
+
+        static bool isValidName(const QString &p_name);
+
+    private:
+        Tag *m_parent = nullptr;
+
+        QString m_name;
+
+        QVector<QSharedPointer<Tag>> m_children;
+    };
+}
+
+#endif // TAG_H

+ 37 - 0
src/core/notebook/tagi.h

@@ -0,0 +1,37 @@
+#ifndef TAGI_H
+#define TAGI_H
+
+#include <QVector>
+
+#include "tag.h"
+
+namespace vnotex
+{
+    class Node;
+
+    // Tag interface for notebook.
+    class TagI
+    {
+    public:
+        virtual ~TagI() = default;
+
+        virtual const QVector<QSharedPointer<Tag>> &getTopLevelTags() const = 0;
+
+        virtual QStringList findNodesOfTag(const QString &p_name) = 0;
+
+        virtual QSharedPointer<Tag> findTag(const QString &p_name) = 0;
+
+        virtual bool newTag(const QString &p_name, const QString &p_parentName) = 0;
+
+        virtual bool renameTag(const QString &p_name, const QString &p_newName) = 0;
+
+        virtual bool updateNodeTags(Node *p_node) = 0;
+
+        virtual bool updateNodeTags(Node *p_node, const QStringList &p_newTags) = 0;
+
+        virtual bool removeTag(const QString &p_name) = 0;
+
+        virtual bool moveTag(const QString &p_name, const QString &p_newParentName) = 0;
+    };
+}
+#endif // TAGI_H

+ 2 - 2
src/core/notebookconfigmgr/bundlenotebookconfigmgr.cpp

@@ -35,7 +35,7 @@ QSharedPointer<NotebookConfig> BundleNotebookConfigMgr::readNotebookConfig() con
 
 void BundleNotebookConfigMgr::writeNotebookConfig()
 {
-    auto config = NotebookConfig::fromNotebook(getCodeVersion(), getNotebook());
+    auto config = NotebookConfig::fromNotebook(getCodeVersion(), getBundleNotebook());
     writeNotebookConfig(*config);
 }
 
@@ -82,7 +82,7 @@ QString BundleNotebookConfigMgr::getDatabasePath()
 
 BundleNotebook *BundleNotebookConfigMgr::getBundleNotebook() const
 {
-    return dynamic_cast<BundleNotebook *>(getNotebook());
+    return static_cast<BundleNotebook *>(getNotebook());
 }
 
 bool BundleNotebookConfigMgr::isBuiltInFile(const Node *p_node, const QString &p_name) const

+ 28 - 39
src/core/notebookconfigmgr/notebookconfig.cpp

@@ -1,7 +1,7 @@
 #include "notebookconfig.h"
 
 #include <notebook/notebookparameters.h>
-#include <notebook/notebook.h>
+#include <notebook/bundlenotebook.h>
 #include <versioncontroller/iversioncontroller.h>
 #include <utils/utils.h>
 #include "exception.h"
@@ -9,22 +9,6 @@
 
 using namespace vnotex;
 
-const QString NotebookConfig::c_version = "version";
-
-const QString NotebookConfig::c_name = "name";
-
-const QString NotebookConfig::c_description = "description";
-
-const QString NotebookConfig::c_imageFolder = "image_folder";
-
-const QString NotebookConfig::c_attachmentFolder = "attachment_folder";
-
-const QString NotebookConfig::c_createdTimeUtc = "created_time";
-
-const QString NotebookConfig::c_versionController = "version_controller";
-
-const QString NotebookConfig::c_configMgr = "config_mgr";
-
 QSharedPointer<NotebookConfig> NotebookConfig::fromNotebookParameters(int p_version,
                                                                       const NotebookParameters &p_paras)
 {
@@ -46,17 +30,19 @@ QJsonObject NotebookConfig::toJson() const
 {
     QJsonObject jobj;
 
-    jobj[NotebookConfig::c_version] = m_version;
-    jobj[NotebookConfig::c_name] = m_name;
-    jobj[NotebookConfig::c_description] = m_description;
-    jobj[NotebookConfig::c_imageFolder] = m_imageFolder;
-    jobj[NotebookConfig::c_attachmentFolder] = m_attachmentFolder;
-    jobj[NotebookConfig::c_createdTimeUtc] = Utils::dateTimeStringUniform(m_createdTimeUtc);
-    jobj[NotebookConfig::c_versionController] = m_versionController;
-    jobj[NotebookConfig::c_configMgr] = m_notebookConfigMgr;
+    jobj[QStringLiteral("version")] = m_version;
+    jobj[QStringLiteral("name")] = m_name;
+    jobj[QStringLiteral("description")] = m_description;
+    jobj[QStringLiteral("image_folder")] = m_imageFolder;
+    jobj[QStringLiteral("attachment_folder")] = m_attachmentFolder;
+    jobj[QStringLiteral("created_time")] = Utils::dateTimeStringUniform(m_createdTimeUtc);
+    jobj[QStringLiteral("version_controller")] = m_versionController;
+    jobj[QStringLiteral("config_mgr")] = m_notebookConfigMgr;
 
     jobj[QStringLiteral("history")] = saveHistory();
 
+    jobj[QStringLiteral("tag_graph")] = m_tagGraph;
+
     jobj[QStringLiteral("extra_configs")] = m_extraConfigs;
 
     return jobj;
@@ -64,32 +50,34 @@ QJsonObject NotebookConfig::toJson() const
 
 void NotebookConfig::fromJson(const QJsonObject &p_jobj)
 {
-    if (!p_jobj.contains(NotebookConfig::c_version)
-        || !p_jobj.contains(NotebookConfig::c_name)
-        || !p_jobj.contains(NotebookConfig::c_createdTimeUtc)
-        || !p_jobj.contains(NotebookConfig::c_versionController)
-        || !p_jobj.contains(NotebookConfig::c_configMgr)) {
+    if (!p_jobj.contains(QStringLiteral("version"))
+        || !p_jobj.contains(QStringLiteral("name"))
+        || !p_jobj.contains(QStringLiteral("created_time"))
+        || !p_jobj.contains(QStringLiteral("version_controller"))
+        || !p_jobj.contains(QStringLiteral("config_mgr"))) {
         Exception::throwOne(Exception::Type::InvalidArgument,
                             QString("failed to read notebook configuration from JSON (%1)").arg(QJsonObjectToString(p_jobj)));
         return;
     }
 
-    m_version = p_jobj[NotebookConfig::c_version].toInt();
-    m_name = p_jobj[NotebookConfig::c_name].toString();
-    m_description = p_jobj[NotebookConfig::c_description].toString();
-    m_imageFolder = p_jobj[NotebookConfig::c_imageFolder].toString();
-    m_attachmentFolder = p_jobj[NotebookConfig::c_attachmentFolder].toString();
-    m_createdTimeUtc = Utils::dateTimeFromStringUniform(p_jobj[NotebookConfig::c_createdTimeUtc].toString());
-    m_versionController = p_jobj[NotebookConfig::c_versionController].toString();
-    m_notebookConfigMgr = p_jobj[NotebookConfig::c_configMgr].toString();
+    m_version = p_jobj[QStringLiteral("version")].toInt();
+    m_name = p_jobj[QStringLiteral("name")].toString();
+    m_description = p_jobj[QStringLiteral("description")].toString();
+    m_imageFolder = p_jobj[QStringLiteral("image_folder")].toString();
+    m_attachmentFolder = p_jobj[QStringLiteral("attachment_folder")].toString();
+    m_createdTimeUtc = Utils::dateTimeFromStringUniform(p_jobj[QStringLiteral("created_time")].toString());
+    m_versionController = p_jobj[QStringLiteral("version_controller")].toString();
+    m_notebookConfigMgr = p_jobj[QStringLiteral("config_mgr")].toString();
 
     loadHistory(p_jobj);
 
+    m_tagGraph = p_jobj[QStringLiteral("tag_graph")].toString();
+
     m_extraConfigs = p_jobj[QStringLiteral("extra_configs")].toObject();
 }
 
 QSharedPointer<NotebookConfig> NotebookConfig::fromNotebook(int p_version,
-                                                            const Notebook *p_notebook)
+                                                            const BundleNotebook *p_notebook)
 {
     auto config = QSharedPointer<NotebookConfig>::create();
 
@@ -102,6 +90,7 @@ QSharedPointer<NotebookConfig> NotebookConfig::fromNotebook(int p_version,
     config->m_versionController = p_notebook->getVersionController()->getName();
     config->m_notebookConfigMgr = p_notebook->getConfigMgr()->getName();
     config->m_history = p_notebook->getHistory();
+    config->m_tagGraph = p_notebook->getTagGraph();
     config->m_extraConfigs = p_notebook->getExtraConfigs();
 
     return config;

+ 5 - 17
src/core/notebookconfigmgr/notebookconfig.h

@@ -15,6 +15,7 @@ namespace vnotex
 {
     class NotebookParameters;
 
+    // Notebook config of BundleNotebook.
     class NotebookConfig
     {
     public:
@@ -24,7 +25,7 @@ namespace vnotex
                                                                      const NotebookParameters &p_paras);
 
         static QSharedPointer<NotebookConfig> fromNotebook(int p_version,
-                                                           const Notebook *p_notebook);
+                                                           const BundleNotebook *p_notebook);
 
         virtual QJsonObject toJson() const;
 
@@ -48,6 +49,9 @@ namespace vnotex
 
         QVector<HistoryItem> m_history;
 
+        // Graph of tags of this notebook like "parent>chlid;parent2>chlid2".
+        QString m_tagGraph;
+
         // Hold all the extra configs for other components or 3rd party plugins.
         // Use a unique name as the key and the value is a QJsonObject.
         QJsonObject m_extraConfigs;
@@ -56,22 +60,6 @@ namespace vnotex
         QJsonArray saveHistory() const;
 
         void loadHistory(const QJsonObject &p_jobj);
-
-        static const QString c_version;
-
-        static const QString c_name;
-
-        static const QString c_description;
-
-        static const QString c_imageFolder;
-
-        static const QString c_attachmentFolder;
-
-        static const QString c_createdTimeUtc;
-
-        static const QString c_versionController;
-
-        static const QString c_configMgr;
     };
 } // ns vnotex
 

+ 2 - 0
src/core/sessionconfig.cpp

@@ -221,6 +221,7 @@ QJsonObject SessionConfig::saveStateAndGeometry() const
     writeByteArray(obj, QStringLiteral("main_window_state"), m_mainWindowStateGeometry.m_mainState);
     writeByteArray(obj, QStringLiteral("main_window_geometry"), m_mainWindowStateGeometry.m_mainGeometry);
     writeStringList(obj, QStringLiteral("visible_docks_before_expand"), m_mainWindowStateGeometry.m_visibleDocksBeforeExpand);
+    writeByteArray(obj, QStringLiteral("tag_explorer_state"), m_mainWindowStateGeometry.m_tagExplorerState);
     return obj;
 }
 
@@ -351,6 +352,7 @@ void SessionConfig::loadStateAndGeometry(const QJsonObject &p_session)
     m_mainWindowStateGeometry.m_mainState = readByteArray(obj, QStringLiteral("main_window_state"));
     m_mainWindowStateGeometry.m_mainGeometry = readByteArray(obj, QStringLiteral("main_window_geometry"));
     m_mainWindowStateGeometry.m_visibleDocksBeforeExpand = readStringList(obj, QStringLiteral("visible_docks_before_expand"));
+    m_mainWindowStateGeometry.m_tagExplorerState = readByteArray(obj, QStringLiteral("tag_explorer_state"));
 }
 
 QByteArray SessionConfig::getViewAreaSessionAndClear()

+ 4 - 1
src/core/sessionconfig.h

@@ -36,7 +36,8 @@ namespace vnotex
             {
                 return m_mainState == p_other.m_mainState
                        && m_mainGeometry == p_other.m_mainGeometry
-                       && m_visibleDocksBeforeExpand == p_other.m_visibleDocksBeforeExpand;
+                       && m_visibleDocksBeforeExpand == p_other.m_visibleDocksBeforeExpand
+                       && m_tagExplorerState == p_other.m_tagExplorerState;
             }
 
             QByteArray m_mainState;
@@ -44,6 +45,8 @@ namespace vnotex
             QByteArray m_mainGeometry;
 
             QStringList m_visibleDocksBeforeExpand;
+
+            QByteArray m_tagExplorerState;
         };
 
         enum OpenGL

+ 12 - 1
src/core/widgetconfig.cpp

@@ -42,6 +42,8 @@ void WidgetConfig::init(const QJsonObject &p_app,
     m_mainWindowKeepDocksExpandingContentArea = READSTRLIST(QStringLiteral("main_window_keep_docks_expanding_content_area"));
 
     m_snippetPanelBuiltInSnippetsVisible = READBOOL(QStringLiteral("snippet_panel_builtin_snippets_visible"));
+
+    m_tagExplorerTwoColumnsEnabled = READBOOL(QStringLiteral("tag_explorer_two_columns_enabled"));
 }
 
 QJsonObject WidgetConfig::toJson() const
@@ -59,7 +61,7 @@ QJsonObject WidgetConfig::toJson() const
     obj[QStringLiteral("node_explorer_close_before_open_with_enabled")] = m_nodeExplorerCloseBeforeOpenWithEnabled;
 
     obj[QStringLiteral("search_panel_advanced_settings_visible")] = m_searchPanelAdvancedSettingsVisible;
-    obj[QStringLiteral("snippet_panel_builtin_snippets_visible")] = m_snippetPanelBuiltInSnippetsVisible;
+    obj[QStringLiteral("tag_explorer_two_columns_enabled")] = m_tagExplorerTwoColumnsEnabled;
     writeStringList(obj,
                     QStringLiteral("main_window_keep_docks_expanding_content_area"),
                     m_mainWindowKeepDocksExpandingContentArea);
@@ -176,3 +178,12 @@ void WidgetConfig::setSnippetPanelBuiltInSnippetsVisible(bool p_visible)
     updateConfig(m_snippetPanelBuiltInSnippetsVisible, p_visible, this);
 }
 
+bool WidgetConfig::getTagExplorerTwoColumnsEnabled() const
+{
+    return m_tagExplorerTwoColumnsEnabled;
+}
+
+void WidgetConfig::setTagExplorerTwoColumnsEnabled(bool p_enabled)
+{
+    updateConfig(m_tagExplorerTwoColumnsEnabled, p_enabled, this);
+}

+ 6 - 0
src/core/widgetconfig.h

@@ -51,6 +51,9 @@ namespace vnotex
         bool isSnippetPanelBuiltInSnippetsVisible() const;
         void setSnippetPanelBuiltInSnippetsVisible(bool p_visible);
 
+        bool getTagExplorerTwoColumnsEnabled() const;
+        void setTagExplorerTwoColumnsEnabled(bool p_enabled);
+
     private:
         int m_outlineAutoExpandedLevel = 6;
 
@@ -74,6 +77,9 @@ namespace vnotex
         QStringList m_mainWindowKeepDocksExpandingContentArea;
 
         bool m_snippetPanelBuiltInSnippetsVisible = true;
+
+        // Whether enable two columns for tag explorer.
+        bool m_tagExplorerTwoColumnsEnabled = false;
     };
 }
 

+ 5 - 2
src/data/core/core.qrc

@@ -15,7 +15,9 @@
         <file>icons/read_editor.svg</file>
         <file>icons/expand.svg</file>
         <file>icons/fullscreen.svg</file>
-        <file>icons/tag_explorer.svg</file>
+        <file>icons/tag_dock.svg</file>
+        <file>icons/tag.svg</file>
+        <file>icons/tag_selected.svg</file>
         <file>icons/help.svg</file>
         <file>icons/menu.svg</file>
         <file>icons/settings.svg</file>
@@ -34,7 +36,7 @@
         <file>icons/file_node.svg</file>
         <file>icons/folder_node.svg</file>
         <file>icons/manage_notebooks.svg</file>
-        <file>icons/up_parent_node.svg</file>
+        <file>icons/up_level.svg</file>
         <file>icons/properties.svg</file>
         <file>icons/recycle_bin.svg</file>
         <file>icons/scan_import.svg</file>
@@ -43,6 +45,7 @@
         <file>icons/buffer.svg</file>
         <file>icons/attachment_editor.svg</file>
         <file>icons/attachment_full_editor.svg</file>
+        <file>icons/tag_editor.svg</file>
         <file>icons/split_menu.svg</file>
         <file>icons/split_window_list.svg</file>
         <file>icons/type_heading_editor.svg</file>

+ 1 - 10
src/data/core/icons/properties.svg

@@ -1,10 +1 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 16.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 width="512px" height="512px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
-<g>
-	<polygon style="fill:#000000" points="288,448 288,192 192,192 192,208 224,208 224,448 192,448 192,464 320,464 320,448 	"/>
-	<path style="fill:#000000" d="M255.8,144.5c26.6,0,48.2-21.6,48.2-48.2s-21.6-48.2-48.2-48.2c-26.6,0-48.2,21.6-48.2,48.2S229.2,144.5,255.8,144.5z"/>
-</g>
-</svg>
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1633945130444" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5939" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M512 0C229.23 0 0 229.23 0 512s229.23 512 512 512 512-229.23 512-512S794.77 0 512 0zM512 928c-229.75 0-416-186.25-416-416S282.25 96 512 96s416 186.25 416 416S741.75 928 512 928z" p-id="5940" fill="#000000"></path><path d="M537.64 343.452c47.074 0 83.266-37.528 83.266-78.072 0-32.46-20.832-60.878-62.496-60.878-54.816 0-82.178 44.618-82.178 77.11C475.144 320.132 498.152 343.452 537.64 343.452z" p-id="5941" fill="#000000"></path><path d="M533.162 728.934c-7.648 0-10.914-10.136-3.264-39.55l43.25-166.406c16.386-60.848 10.944-100.398-21.92-100.398-39.456 0-131.458 39.83-211.458 107.798l16.416 27.392c25.246-17.256 67.906-34.762 77.792-34.762 7.648 0 6.56 10.168 0 35.508l-37.746 158.292c-23.008 89.266 1.088 109.538 33.984 109.538 32.864 0 117.808-30.47 195.57-109.632l-18.656-25.34C575.354 716.714 543.05 728.934 533.162 728.934z" p-id="5942" fill="#000000"></path></svg>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 7
src/data/core/icons/recycle_bin.svg


+ 1 - 8
src/data/core/icons/sort.svg

@@ -1,8 +1 @@
-<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
- <!-- Created with SVG-edit - http://svg-edit.googlecode.com/ -->
- <g>
-  <title>Layer 2</title>
-  <text fill="#000000" stroke-width="0" x="50.16984" y="94.53242" id="svg_1" font-size="24" font-family="Sans-serif" text-anchor="middle" xml:space="preserve" transform="matrix(14.638787563577418,0,0,15.425402876908336,-563.6705867690341,-1147.819919301283) " stroke="#000000">A</text>
-  <text id="svg_3" fill="#000000" stroke-width="0" x="42.10906" y="100.56143" font-size="24" font-family="Sans-serif" text-anchor="middle" xml:space="preserve" transform="matrix(14.638787563577418,0,0,15.425402876908336,-251.02721518700292,-1086.2459218037245) " stroke="#000000">Z</text>
- </g>
-</svg>
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1633747200250" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5795" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M426.666667 554.666667 426.666667 469.333333 768 469.333333 768 554.666667 426.666667 554.666667M426.666667 810.666667 426.666667 725.333333 597.333333 725.333333 597.333333 810.666667 426.666667 810.666667M426.666667 298.666667 426.666667 213.333333 938.666667 213.333333 938.666667 298.666667 426.666667 298.666667M256 725.333333 362.666667 725.333333 213.333333 874.666667 64 725.333333 170.666667 725.333333 170.666667 298.666667 64 298.666667 213.333333 149.333333 362.666667 298.666667 256 298.666667 256 725.333333Z" p-id="5796" fill="#000000"></path></svg>

+ 0 - 0
src/data/core/icons/tag_explorer.svg → src/data/core/icons/tag.svg


+ 12 - 0
src/data/core/icons/tag_dock.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="512px" height="512px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
+<g>
+	<path fill="#000000" d="M448,64V32H288L32,320l160,160l23.471-23.904L240,480l240-272V64H448z M192,457.371L54.39,320L294.621,48H432v16v16
+		v105.377l-216.555,247.99l-11.34,11.363L192,457.371z M464,201.377L240,457.371l-13.182-12.65L448,192V80h16V201.377z"/>
+	<path fill="#000000" d="M352,160c17.645,0,32-14.355,32-32s-14.355-32-32-32s-32,14.355-32,32S334.355,160,352,160z M352,112
+		c8.836,0,16,7.163,16,16s-7.164,16-16,16s-16-7.163-16-16S343.164,112,352,112z"/>
+</g>
+</svg>

+ 12 - 0
src/data/core/icons/tag_editor.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="512px" height="512px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
+<g>
+	<path fill="#000000" d="M448,64V32H288L32,320l160,160l23.471-23.904L240,480l240-272V64H448z M192,457.371L54.39,320L294.621,48H432v16v16
+		v105.377l-216.555,247.99l-11.34,11.363L192,457.371z M464,201.377L240,457.371l-13.182-12.65L448,192V80h16V201.377z"/>
+	<path fill="#000000" d="M352,160c17.645,0,32-14.355,32-32s-14.355-32-32-32s-32,14.355-32,32S334.355,160,352,160z M352,112
+		c8.836,0,16,7.163,16,16s-7.164,16-16,16s-16-7.163-16-16S343.164,112,352,112z"/>
+</g>
+</svg>

+ 1 - 0
src/data/core/icons/tag_selected.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1634127135446" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6562" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M928 128 928 402.754 454.306 934.96 480 960 960 416 960 128Z" p-id="6563" fill="#000000"></path><path d="M576 64 64 640l320 320 46.942-47.808 22.696-22.75L896 384 896 160 896 128 896 64 576 64zM704 320c-35.29 0-64-28.71-64-64s28.71-64 64-64 64 28.71 64 64S739.29 320 704 320z" p-id="6564" fill="#000000"></path><path d="M704 256m-32 0a16 16 0 1 0 64 0 16 16 0 1 0-64 0Z" p-id="6565" fill="#000000"></path></svg>

+ 0 - 0
src/data/core/icons/up_parent_node.svg → src/data/core/icons/up_level.svg


+ 1 - 15
src/data/core/icons/view.svg

@@ -1,15 +1 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 16.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 width="512px" height="512px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
-<g>
-	<path fill="#000000" d="M256,128c-81.9,0-145.7,48.8-224,128c67.4,67.7,124,128,224,128c99.9,0,173.4-76.4,224-126.6
-		C428.2,198.6,354.8,128,256,128z M256,347.3c-49.4,0-89.6-41-89.6-91.3c0-50.4,40.2-91.3,89.6-91.3s89.6,41,89.6,91.3
-		C345.6,306.4,305.4,347.3,256,347.3z"/>
-	<g>
-		<path fill="#000000" d="M256,224c0-7.9,2.9-15.1,7.6-20.7c-2.5-0.4-5-0.6-7.6-0.6c-28.8,0-52.3,23.9-52.3,53.3c0,29.4,23.5,53.3,52.3,53.3
-			s52.3-23.9,52.3-53.3c0-2.3-0.2-4.6-0.4-6.9c-5.5,4.3-12.3,6.9-19.8,6.9C270.3,256,256,241.7,256,224z"/>
-	</g>
-</g>
-</svg>
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1633747385916" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14318" width="512" height="512" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css"></style></defs><path d="M332.256 162.432c-12-4.992-25.728-2.24-34.88 6.944l-192 192c-12.512 12.512-12.512 32.736 0 45.248s32.736 12.512 45.248 0L288 269.248 288 800c0 17.696 14.336 32 32 32s32-14.304 32-32L352 192C352 179.072 344.192 167.392 332.256 162.432z" p-id="14319" fill="#000000"></path><path d="M918.624 617.376c-12.512-12.512-32.736-12.512-45.248 0L768 722.752 768 192c0-17.664-14.304-32-32-32s-32 14.336-32 32l0 608c0 12.928 7.776 24.64 19.744 29.568C727.712 831.232 731.872 832 736 832c8.32 0 16.512-3.264 22.624-9.376l160-160C931.136 650.112 931.136 629.888 918.624 617.376z" p-id="14320" fill="#000000"></path></svg>

+ 5 - 2
src/data/core/vnotex.json

@@ -23,6 +23,7 @@
             "SnippetDock" : "Ctrl+G, S",
             "LocationListDock" : "Ctrl+G, C",
             "HistoryDock" : "",
+            "TagDock" : "",
             "Search" : "Ctrl+Alt+F",
             "NavigationMode" : "Ctrl+G, W",
             "LocateNode" : "Ctrl+G, D",
@@ -117,7 +118,8 @@
                 "FindAndReplace" : "Ctrl+F",
                 "FindNext" : "F3",
                 "FindPrevious" : "Shift+F3",
-                "ApplySnippet" : "Ctrl+G, I"
+                "ApplySnippet" : "Ctrl+G, I",
+                "Tag" : "Ctrl+G, B"
             },
             "spell_check_auto_detect_language" : false,
             "spell_check_default_dictionary" : "en_US",
@@ -379,6 +381,7 @@
         "search_panel_advanced_settings_visible" : true,
         "//comment" : "Docks to ignore when expanding content area of main window",
         "main_window_keep_docks_expanding_content_area": ["OutlineDock.vnotex"],
-        "snippet_panel_builtin_snippets_visible" : true
+        "snippet_panel_builtin_snippets_visible" : true,
+        "tag_explorer_two_columns_enabled" : true
     }
 }

+ 4 - 4
src/data/extra/docs/en/shortcuts.md

@@ -2,7 +2,7 @@
 1. All the keys without special notice are **case insensitive**;
 2. On macOS, `Ctrl` corresponds to `Command` except in Vi mode;
 3. The key sequence `Ctrl+G, I` means that first press both `Ctrl` and `G` simultaneously, release them, then press `I` and release;
-4. For a **complete shortcuts list**, please view the `vnotex.json` configuration file.
+4. For a **complete latest shortcuts list**, please view the `vnotex.json` configuration file.
 
 ## General
 - `Ctrl+G E`  
@@ -101,10 +101,10 @@ Insert italic. Press `Ctrl+I` again to exit. Current selected text will be chang
 Insert inline code. Press `Ctrl+;` again to exit. Current selected text will be changed to inline code if exists.
 - `Ctrl+'`  
 Insert fenced code block. Press `Ctrl+'` again to exit. Current selected text will be wrapped into a code block if exists.
-- `Ctrl+,`  
-Insert inline math. Press `Ctrl+,` again to exit. Current selected text will be changed to inline math if exists.
 - `Ctrl+.`  
-Insert math block. Press `Ctrl+.` again to exit. Current selected text will be changed to math block if exists.
+Insert inline math. Press `Ctrl+.` again to exit. Current selected text will be changed to inline math if exists.
+- `Ctrl+G, .`  
+Insert math block. Press `Ctrl+G, .` again to exit. Current selected text will be changed to math block if exists.
 - `Ctrl+/`  
 Insert table.
 - `Ctrl+<Num>`  

+ 4 - 4
src/data/extra/docs/zh_CN/shortcuts.md

@@ -2,7 +2,7 @@
 1. 以下按键除特别说明外,都不区分大小写;
 2. 在 macOS 下,`Ctrl` 对应于 `Command`,在 Vi 模式下除外;
 3. 按键序列 `Ctrl+G, I` 表示先同时按下 `Ctrl` 和 `G`,释放,然后按下 `I` 并释放;
-4. 可以通过查看配置文件 `vnotex.json` 来获取一个**完整的快捷键列表**。
+4. 可以通过查看配置文件 `vnotex.json` 来获取一个**完整的最新的快捷键列表**。
 
 ## 通用
 - `Ctrl+G E`  
@@ -101,10 +101,10 @@ VNote 的很多部件均支持`Ctrl+J`和`Ctrl+K`导航。
 插入行内代码;再次按`Ctrl+;`退出。如果已经选择文本,则将当前选择文本改为行内代码。
 - `Ctrl+'`  
 插入代码块;再次按`Ctrl+'`退出。如果已经选择文本,则将当前选择文本嵌入到代码块中。
-- `Ctrl+,`  
-插入公式;再次按`Ctrl+,`退出。如果已经选择文本,则将当前选择文本改为公式。
 - `Ctrl+.`  
-插入公式块;再次按`Ctrl+.`退出。如果已经选择文本,则将当前选择文本改为公式块。
+插入公式;再次按`Ctrl+.`退出。如果已经选择文本,则将当前选择文本改为公式。
+- `Ctrl+G, .`  
+插入公式块;再次按`Ctrl+G, .`退出。如果已经选择文本,则将当前选择文本改为公式块。
 - `Ctrl+/`  
 插入表格。
 - `Ctrl+<Num>`  

+ 1 - 1
src/main.cpp

@@ -48,7 +48,7 @@ int main(int argc, char *argv[])
 #if defined(Q_OS_WIN)
     {
         auto option = SessionConfig::getOpenGLAtBootstrap();
-        qInfo() << "OpenGL option" << SessionConfig::openGLToString(option);
+        qDebug() << "OpenGL option" << SessionConfig::openGLToString(option);
         switch (option) {
         case SessionConfig::OpenGL::Desktop:
             QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL);

+ 5 - 0
src/search/searcher.cpp

@@ -470,6 +470,11 @@ bool Searcher::firstPhaseSearch(Notebook *p_notebook, QVector<SearchSecondPhaseI
             return true;
         }
 
+        if (p_notebook->isRecycleBinNode(child.data())) {
+            qDebug() << "skipped searching recycle bin";
+            continue;
+        }
+
         if (child->hasContent() && testTarget(SearchTarget::SearchFile)) {
             if (!firstPhaseSearch(child.data(), p_secondPhaseItems)) {
                 return false;

+ 3 - 6
src/utils/pathutils.cpp

@@ -4,8 +4,6 @@
 #include <QFileInfo>
 #include <QRegularExpression>
 #include <QImageReader>
-#include <QValidator>
-#include <QRegularExpressionValidator>
 
 using namespace vnotex;
 
@@ -138,13 +136,12 @@ bool PathUtils::isLegalPath(const QString &p_path)
     }
 
     bool ret = false;
-    int pos = -1;
     QString basePath = parentDirPath(p_path);
     QString name = dirName(p_path);
-    QScopedPointer<QValidator> validator(new QRegularExpressionValidator(QRegularExpression(c_fileNameRegularExpression)));
+    QRegularExpression nameRegExp(c_fileNameRegularExpression);
     while (!name.isEmpty()) {
-        QValidator::State validFile = validator->validate(name, pos);
-        if (validFile != QValidator::Acceptable) {
+        auto match = nameRegExp.match(name);
+        if (!match.hasMatch()) {
             break;
         }
 

+ 80 - 0
src/widgets/dialogs/levellabelwithupbutton.cpp

@@ -0,0 +1,80 @@
+#include "levellabelwithupbutton.h"
+
+#include <QPushButton>
+#include <QLabel>
+#include <QHBoxLayout>
+
+#include <utils/iconutils.h>
+#include <core/vnotex.h>
+
+using namespace vnotex;
+
+LevelLabelWithUpButton::LevelLabelWithUpButton(QWidget *p_parent)
+    : QWidget(p_parent)
+{
+    setupUI();
+}
+
+void LevelLabelWithUpButton::setupUI()
+{
+    auto mainLayout = new QHBoxLayout(this);
+    mainLayout->setContentsMargins(0, 0, 0, 0);
+
+    m_label = new QLabel(this);
+    mainLayout->addWidget(m_label, 1);
+
+    const auto iconFile = VNoteX::getInst().getThemeMgr().getIconFile("up_level.svg");
+    m_upButton = new QPushButton(IconUtils::fetchIconWithDisabledState(iconFile),
+                                 tr("Up"),
+                                 this);
+    m_upButton->setToolTip(tr("Go one level up"));
+    connect(m_upButton, &QPushButton::clicked,
+            this, [this]() {
+                if (m_levelIdx < m_levels.size() - 1) {
+                    ++m_levelIdx;
+                    updateLabelAndButton();
+                    emit levelChanged();
+                }
+            });
+    mainLayout->addWidget(m_upButton, 0);
+
+    updateLabelAndButton();
+}
+
+void LevelLabelWithUpButton::updateLabelAndButton()
+{
+    if (m_levels.isEmpty()) {
+        m_label->clear();
+    } else {
+        Q_ASSERT(m_levelIdx < m_levels.size());
+        m_label->setText(m_levels[m_levelIdx].m_name);
+    }
+
+    m_upButton->setVisible(!m_readOnly && (m_levelIdx < m_levels.size() - 1));
+}
+
+const LevelLabelWithUpButton::Level &LevelLabelWithUpButton::getLevel() const
+{
+    Q_ASSERT(m_levelIdx < m_levels.size());
+    return m_levels[m_levelIdx];
+}
+
+void LevelLabelWithUpButton::setLevels(const QVector<Level> &p_levels)
+{
+    m_levels = p_levels;
+    Q_ASSERT(!m_levels.isEmpty());
+    m_levelIdx = 0;
+
+    updateLabelAndButton();
+    emit levelChanged();
+}
+
+void LevelLabelWithUpButton::setReadOnly(bool p_readonly)
+{
+    if (m_readOnly == p_readonly) {
+        return;
+    }
+
+    m_readOnly = p_readonly;
+    updateLabelAndButton();
+}

+ 52 - 0
src/widgets/dialogs/levellabelwithupbutton.h

@@ -0,0 +1,52 @@
+#ifndef LEVELLABELWITHUPBUTTON_H
+#define LEVELLABELWITHUPBUTTON_H
+
+#include <QWidget>
+
+class QLabel;
+class QPushButton;
+
+namespace vnotex
+{
+    // Used to navigate through a series of levels.
+    class LevelLabelWithUpButton : public QWidget
+    {
+        Q_OBJECT
+    public:
+        struct Level
+        {
+            QString m_name;
+
+            const void *m_data = nullptr;
+        };
+
+        LevelLabelWithUpButton(QWidget *p_parent = nullptr);
+
+        const Level &getLevel() const;
+
+        // From bottom to up.
+        void setLevels(const QVector<Level> &p_levels);
+
+        void setReadOnly(bool p_readonly);
+
+    signals:
+        void levelChanged();
+
+    private:
+        void setupUI();
+
+        void updateLabelAndButton();
+
+        QLabel *m_label = nullptr;
+
+        QPushButton *m_upButton = nullptr;
+
+        QVector<Level> m_levels;
+
+        int m_levelIdx = -1;
+
+        bool m_readOnly = false;
+    };
+} // ns vnotex
+
+#endif // LEVELLABELWITHUPBUTTON_H

+ 5 - 4
src/widgets/dialogs/newfolderdialog.cpp

@@ -2,11 +2,12 @@
 
 #include <QLineEdit>
 
-#include "notebook/notebook.h"
-#include "notebook/node.h"
-#include "../widgetsfactory.h"
+#include <notebook/notebook.h>
+#include <notebook/node.h>
 #include <utils/pathutils.h>
-#include "exception.h"
+#include <core/exception.h>
+
+#include "../widgetsfactory.h"
 #include "nodeinfowidget.h"
 
 using namespace vnotex;

+ 104 - 0
src/widgets/dialogs/newtagdialog.cpp

@@ -0,0 +1,104 @@
+#include "newtagdialog.h"
+
+#include <QFormLayout>
+
+#include <notebook/tagi.h>
+
+#include "../widgetsfactory.h"
+#include "levellabelwithupbutton.h"
+#include "../lineeditwithsnippet.h"
+
+using namespace vnotex;
+
+NewTagDialog::NewTagDialog(TagI *p_tagI, Tag *p_tag, QWidget *p_parent)
+    : ScrollDialog(p_parent),
+      m_tagI(p_tagI),
+      m_parentTag(p_tag)
+{
+    setupUI();
+
+    m_nameLineEdit->setFocus();
+}
+
+static QVector<LevelLabelWithUpButton::Level> tagToLevels(const Tag *p_tag)
+{
+    QVector<LevelLabelWithUpButton::Level> levels;
+    while (p_tag) {
+        LevelLabelWithUpButton::Level level;
+        level.m_name = p_tag->fetchPath();
+        level.m_data = static_cast<const void *>(p_tag);
+        levels.push_back(level);
+        p_tag = p_tag->getParent();
+    }
+
+    // Append an empty level.
+    levels.push_back(LevelLabelWithUpButton::Level());
+
+    return levels;
+}
+
+void NewTagDialog::setupUI()
+{
+    auto mainWidget = new QWidget(this);
+    setCentralWidget(mainWidget);
+
+    auto mainLayout = WidgetsFactory::createFormLayout(mainWidget);
+
+    {
+        m_parentTagLabel = new LevelLabelWithUpButton(this);
+        m_parentTagLabel->setLevels(tagToLevels(m_parentTag));
+        mainLayout->addRow(tr("Location:"), m_parentTagLabel);
+    }
+
+    m_nameLineEdit = WidgetsFactory::createLineEditWithSnippet(mainWidget);
+    mainLayout->addRow(tr("Name:"), m_nameLineEdit);
+
+    setDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+
+    setWindowTitle(tr("New Tag"));
+}
+
+bool NewTagDialog::validateInputs()
+{
+    bool valid = true;
+    QString msg;
+
+    auto name = getTagName();
+    if (!Tag::isValidName(name)) {
+        valid = false;
+        msg = tr("Please specify a valid name for the tag.");
+    } else if (m_tagI->findTag(name)) {
+        valid = false;
+        msg = tr("Name conflicts with existing tag.");
+    }
+
+    setInformationText(msg, valid ? ScrollDialog::InformationLevel::Info
+                                  : ScrollDialog::InformationLevel::Error);
+    return valid;
+}
+
+bool NewTagDialog::newTag()
+{
+    const Tag *parentTag = static_cast<const Tag *>(m_parentTagLabel->getLevel().m_data);
+    const auto parentName = parentTag ? parentTag->name() : QString();
+    const auto name = getTagName();
+    if (!m_tagI->newTag(name, parentName)) {
+        setInformationText(tr("Failed to create tag (%1).").arg(name), ScrollDialog::InformationLevel::Error);
+        // Tags maybe updated. Don't allow operation for now.
+        setButtonEnabled(QDialogButtonBox::Ok, false);
+        return false;
+    }
+    return true;
+}
+
+void NewTagDialog::acceptedButtonClicked()
+{
+    if (validateInputs() && newTag()) {
+        accept();
+    }
+}
+
+QString NewTagDialog::getTagName() const
+{
+    return m_nameLineEdit->evaluatedText();
+}

+ 42 - 0
src/widgets/dialogs/newtagdialog.h

@@ -0,0 +1,42 @@
+#ifndef NEWTAGDIALOG_H
+#define NEWTAGDIALOG_H
+
+#include "scrolldialog.h"
+
+namespace vnotex
+{
+    class TagI;
+    class Tag;
+    class LevelLabelWithUpButton;
+    class LineEditWithSnippet;
+
+    class NewTagDialog : public ScrollDialog
+    {
+        Q_OBJECT
+    public:
+        // New a tag under @p_tag.
+        NewTagDialog(TagI *p_tagI, Tag *p_tag, QWidget *p_parent = nullptr);
+
+    protected:
+        void acceptedButtonClicked() Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        bool validateInputs();
+
+        bool newTag();
+
+        QString getTagName() const;
+
+        TagI *m_tagI = nullptr;
+
+        Tag *m_parentTag = nullptr;
+
+        LevelLabelWithUpButton *m_parentTagLabel = nullptr;
+
+        LineEditWithSnippet *m_nameLineEdit = nullptr;
+    };
+}
+
+#endif // NEWTAGDIALOG_H

+ 25 - 8
src/widgets/dialogs/nodeinfowidget.cpp

@@ -10,7 +10,7 @@
 #include <utils/pathutils.h>
 #include <utils/utils.h>
 #include "exception.h"
-#include "nodelabelwithupbutton.h"
+#include "levellabelwithupbutton.h"
 #include <utils/widgetutils.h>
 #include <buffer/filetypehelper.h>
 #include "../lineeditwithsnippet.h"
@@ -35,6 +35,20 @@ NodeInfoWidget::NodeInfoWidget(const Node *p_parentNode,
     setupUI(p_parentNode, p_flags);
 }
 
+static QVector<LevelLabelWithUpButton::Level> nodeToLevels(const Node *p_node)
+{
+    QVector<LevelLabelWithUpButton::Level> levels;
+    while (p_node) {
+        LevelLabelWithUpButton::Level level;
+        level.m_name = p_node->fetchPath();
+        level.m_data = static_cast<const void *>(p_node);
+        levels.push_back(level);
+        p_node = p_node->getParent();
+    }
+
+    return levels;
+}
+
 void NodeInfoWidget::setupUI(const Node *p_parentNode, Node::Flags p_newNodeFlags)
 {
     const bool createMode = m_mode == Mode::Create;
@@ -45,11 +59,14 @@ void NodeInfoWidget::setupUI(const Node *p_parentNode, Node::Flags p_newNodeFlag
     m_mainLayout->addRow(tr("Notebook:"),
                          new QLabel(p_parentNode->getNotebook()->getName(), this));
 
-    m_parentNodeLabel = new NodeLabelWithUpButton(p_parentNode, this);
-    m_parentNodeLabel->setReadOnly(!createMode);
-    connect(m_parentNodeLabel, &NodeLabelWithUpButton::nodeChanged,
-            this, &NodeInfoWidget::inputEdited);
-    m_mainLayout->addRow(tr("Location:"), m_parentNodeLabel);
+    {
+        m_parentNodeLabel = new LevelLabelWithUpButton(this);
+        m_parentNodeLabel->setReadOnly(!createMode);
+        m_parentNodeLabel->setLevels(nodeToLevels(p_parentNode));
+        connect(m_parentNodeLabel, &LevelLabelWithUpButton::levelChanged,
+                this, &NodeInfoWidget::inputEdited);
+        m_mainLayout->addRow(tr("Location:"), m_parentNodeLabel);
+    }
 
     if (createMode && isNote) {
         setupFileTypeComboBox(this);
@@ -115,7 +132,7 @@ const Notebook *NodeInfoWidget::getNotebook() const
 
 const Node *NodeInfoWidget::getParentNode() const
 {
-    return m_parentNodeLabel->getNode();
+    return static_cast<const Node *>(m_parentNodeLabel->getLevel().m_data);
 }
 
 void NodeInfoWidget::setNode(const Node *p_node)
@@ -129,7 +146,7 @@ void NodeInfoWidget::setNode(const Node *p_node)
     if (m_node) {
         Q_ASSERT(getNotebook() == m_node->getNotebook());
         m_nameLineEdit->setText(m_node->getName());
-        m_parentNodeLabel->setNode(m_node->getParent());
+        m_parentNodeLabel->setLevels(nodeToLevels(m_node->getParent()));
 
         auto createdTime = Utils::dateTimeString(m_node->getCreatedTimeUtc().toLocalTime());
         m_createdDateTimeLabel->setText(createdTime);

+ 2 - 2
src/widgets/dialogs/nodeinfowidget.h

@@ -13,7 +13,7 @@ class QComboBox;
 namespace vnotex
 {
     class Notebook;
-    class NodeLabelWithUpButton;
+    class LevelLabelWithUpButton;
     class LineEditWithSnippet;
 
     class NodeInfoWidget : public QWidget
@@ -59,7 +59,7 @@ namespace vnotex
 
         LineEditWithSnippet *m_nameLineEdit = nullptr;
 
-        NodeLabelWithUpButton *m_parentNodeLabel = nullptr;
+        LevelLabelWithUpButton *m_parentNodeLabel = nullptr;
 
         QLabel *m_createdDateTimeLabel = nullptr;
 

+ 0 - 76
src/widgets/dialogs/nodelabelwithupbutton.cpp

@@ -1,76 +0,0 @@
-#include "nodelabelwithupbutton.h"
-
-#include <QPushButton>
-#include <QLabel>
-#include <QHBoxLayout>
-
-#include "notebook/node.h"
-#include <utils/iconutils.h>
-#include "vnotex.h"
-
-using namespace vnotex;
-
-NodeLabelWithUpButton::NodeLabelWithUpButton(const Node *p_node, QWidget *p_parent)
-    : QWidget(p_parent),
-      m_node(p_node)
-{
-    setupUI();
-}
-
-void NodeLabelWithUpButton::setupUI()
-{
-    auto mainLayout = new QHBoxLayout(this);
-    mainLayout->setContentsMargins(0, 0, 0, 0);
-
-    m_label = new QLabel(this);
-    mainLayout->addWidget(m_label, 1);
-
-    auto iconFile = VNoteX::getInst().getThemeMgr().getIconFile("up_parent_node.svg");
-    m_upButton = new QPushButton(IconUtils::fetchIconWithDisabledState(iconFile),
-                                 tr("Up"),
-                                 this);
-    m_upButton->setToolTip(tr("Create note under an upper level node"));
-    connect(m_upButton, &QPushButton::clicked,
-            this, [this]() {
-                if (!m_node->isRoot()) {
-                    m_node = m_node->getParent();
-                    updateLabelAndButton();
-                    emit nodeChanged(m_node);
-                }
-            });
-    mainLayout->addWidget(m_upButton, 0);
-
-    updateLabelAndButton();
-}
-
-void NodeLabelWithUpButton::updateLabelAndButton()
-{
-    m_label->setText(m_node->fetchPath());
-    m_upButton->setVisible(!m_readOnly && !m_node->isRoot());
-}
-
-const Node *NodeLabelWithUpButton::getNode() const
-{
-    return m_node;
-}
-
-void NodeLabelWithUpButton::setNode(const Node *p_node)
-{
-    if (m_node == p_node) {
-        return;
-    }
-
-    m_node = p_node;
-    updateLabelAndButton();
-    emit nodeChanged(m_node);
-}
-
-void NodeLabelWithUpButton::setReadOnly(bool p_readonly)
-{
-    if (m_readOnly == p_readonly) {
-        return;
-    }
-
-    m_readOnly = p_readonly;
-    updateLabelAndButton();
-}

+ 0 - 43
src/widgets/dialogs/nodelabelwithupbutton.h

@@ -1,43 +0,0 @@
-#ifndef NODELABELWITHUPBUTTON_H
-#define NODELABELWITHUPBUTTON_H
-
-#include <QWidget>
-
-class QLabel;
-class QPushButton;
-
-namespace vnotex
-{
-    class Node;
-
-    class NodeLabelWithUpButton : public QWidget
-    {
-        Q_OBJECT
-    public:
-        NodeLabelWithUpButton(const Node *p_node, QWidget *p_parent = nullptr);
-
-        const Node *getNode() const;
-
-        void setNode(const Node *p_node);
-
-        void setReadOnly(bool p_readonly);
-
-    signals:
-        void nodeChanged(const Node *p_node);
-
-    private:
-        void setupUI();
-
-        void updateLabelAndButton();
-
-        QLabel *m_label = nullptr;
-
-        QPushButton *m_upButton = nullptr;
-
-        const Node *m_node = nullptr;
-
-        bool m_readOnly = false;
-    };
-} // ns vnotex
-
-#endif // NODELABELWITHUPBUTTON_H

+ 7 - 6
src/widgets/dialogs/notepropertiesdialog.cpp

@@ -1,15 +1,16 @@
 #include "notepropertiesdialog.h"
 
-#include "notebook/notebook.h"
-#include "notebook/node.h"
-#include "../widgetsfactory.h"
+#include <notebook/notebook.h>
+#include <notebook/node.h>
 #include <utils/pathutils.h>
-#include "exception.h"
-#include "nodeinfowidget.h"
-#include "../lineedit.h"
+#include <core/exception.h>
 #include <core/events.h>
 #include <core/vnotex.h>
 
+#include "../widgetsfactory.h"
+#include "nodeinfowidget.h"
+#include "../lineedit.h"
+
 using namespace vnotex;
 
 NotePropertiesDialog::NotePropertiesDialog(Node *p_node, QWidget *p_parent)

+ 82 - 0
src/widgets/dialogs/renametagdialog.cpp

@@ -0,0 +1,82 @@
+#include "renametagdialog.h"
+
+#include <QFormLayout>
+
+#include <notebook/tagi.h>
+#include <utils/widgetutils.h>
+
+#include "../widgetsfactory.h"
+#include "../lineeditwithsnippet.h"
+
+using namespace vnotex;
+
+RenameTagDialog::RenameTagDialog(TagI *p_tagI, const QString &p_name, QWidget *p_parent)
+    : ScrollDialog(p_parent),
+      m_tagI(p_tagI),
+      m_tagName(p_name)
+{
+    setupUI();
+
+    m_nameLineEdit->setFocus();
+    WidgetUtils::selectBaseName(m_nameLineEdit);
+}
+
+void RenameTagDialog::setupUI()
+{
+    auto mainWidget = new QWidget(this);
+    setCentralWidget(mainWidget);
+
+    auto mainLayout = WidgetsFactory::createFormLayout(mainWidget);
+
+    m_nameLineEdit = WidgetsFactory::createLineEditWithSnippet(m_tagName, mainWidget);
+    mainLayout->addRow(tr("Name:"), m_nameLineEdit);
+
+    setDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+
+    setWindowTitle(tr("Rename Tag"));
+}
+
+bool RenameTagDialog::validateInputs()
+{
+    bool valid = true;
+    QString msg;
+
+    auto name = getTagName();
+    if (name == m_tagName) {
+        return true;
+    }
+
+    if (!Tag::isValidName(name)) {
+        valid = false;
+        msg = tr("Please specify a valid name for the tag.");
+    } else if (m_tagI->findTag(name)) {
+        valid = false;
+        msg = tr("Name conflicts with existing tag.");
+    }
+
+    setInformationText(msg, valid ? ScrollDialog::InformationLevel::Info
+                                  : ScrollDialog::InformationLevel::Error);
+    return valid;
+}
+
+bool RenameTagDialog::renameTag()
+{
+    if (!m_tagI->renameTag(m_tagName, getTagName())) {
+        setInformationText(tr("Failed to rename tag (%1) to (%2).").arg(m_tagName, getTagName()), ScrollDialog::InformationLevel::Error);
+        return false;
+    }
+
+    return true;
+}
+
+void RenameTagDialog::acceptedButtonClicked()
+{
+    if (validateInputs() && renameTag()) {
+        accept();
+    }
+}
+
+QString RenameTagDialog::getTagName() const
+{
+    return m_nameLineEdit->evaluatedText();
+}

+ 37 - 0
src/widgets/dialogs/renametagdialog.h

@@ -0,0 +1,37 @@
+#ifndef RENAMETAGDIALOG_H
+#define RENAMETAGDIALOG_H
+
+#include "scrolldialog.h"
+
+namespace vnotex
+{
+    class TagI;
+    class LineEditWithSnippet;
+
+    class RenameTagDialog : public ScrollDialog
+    {
+        Q_OBJECT
+    public:
+        RenameTagDialog(TagI *p_tagI, const QString &p_name, QWidget *p_parent = nullptr);
+
+        QString getTagName() const;
+
+    protected:
+        void acceptedButtonClicked() Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        bool validateInputs();
+
+        bool renameTag();
+
+        TagI *m_tagI = nullptr;
+
+        const QString m_tagName;
+
+        LineEditWithSnippet *m_nameLineEdit = nullptr;
+    };
+}
+
+#endif // RENAMETAGDIALOG_H

+ 0 - 16
src/widgets/dialogs/sortdialog.cpp

@@ -44,22 +44,6 @@ void SortDialog::setupUI(const QString &p_title, const QString &p_info)
         m_treeWidget->setRootIsDecorated(false);
         m_treeWidget->setSelectionMode(QAbstractItemView::ContiguousSelection);
         m_treeWidget->setDragDropMode(QAbstractItemView::InternalMove);
-        connect(static_cast<TreeWidget *>(m_treeWidget), &TreeWidget::rowsMoved,
-                this, [this](int p_first, int p_last, int p_row) {
-                    auto item = m_treeWidget->topLevelItem(p_row);
-                    if (item) {
-                        // Keep all items selected.
-                        m_treeWidget->setCurrentItem(item);
-
-                        const int cnt = p_last - p_first + 1;
-                        for (int i = 0; i < cnt; ++i) {
-                            auto it = m_treeWidget->topLevelItem(p_row + i);
-                            if (it) {
-                                it->setSelected(true);
-                            }
-                        }
-                    }
-                });
         bodyLayout->addWidget(m_treeWidget);
 
         // Buttons for top/up/down/bottom.

+ 58 - 0
src/widgets/dialogs/viewtagsdialog.cpp

@@ -0,0 +1,58 @@
+#include "viewtagsdialog.h"
+
+#include <QFormLayout>
+#include <QLabel>
+
+#include <notebook/node.h>
+
+#include "../widgetsfactory.h"
+#include "../tagviewer.h"
+
+using namespace vnotex;
+
+ViewTagsDialog::ViewTagsDialog(Node *p_node, QWidget *p_parent)
+    : Dialog(p_parent)
+{
+    setupUI();
+
+    setNode(p_node);
+
+    m_tagViewer->setFocus();
+}
+
+void ViewTagsDialog::setupUI()
+{
+    auto mainWidget = new QWidget(this);
+    setCentralWidget(mainWidget);
+
+    auto mainLayout = WidgetsFactory::createFormLayout(mainWidget);
+
+    m_nodeNameLabel = new QLabel(mainWidget);
+    mainLayout->addRow(tr("Name:"), m_nodeNameLabel);
+
+    m_tagViewer = new TagViewer(mainWidget);
+    mainLayout->addRow(tr("Tags:"), m_tagViewer);
+
+    setDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+
+    setWindowTitle(tr("Tags"));
+}
+
+void ViewTagsDialog::acceptedButtonClicked()
+{
+    m_tagViewer->save();
+    accept();
+}
+
+void ViewTagsDialog::setNode(Node *p_node)
+{
+    if (m_node == p_node) {
+        return;
+    }
+
+    m_node = p_node;
+    Q_ASSERT(m_node);
+
+    m_nodeNameLabel->setText(m_node->getName());
+    m_tagViewer->setNode(m_node);
+}

+ 35 - 0
src/widgets/dialogs/viewtagsdialog.h

@@ -0,0 +1,35 @@
+#ifndef VIEWTAGSDIALOG_H
+#define VIEWTAGSDIALOG_H
+
+#include "dialog.h"
+
+class QLabel;
+
+namespace vnotex
+{
+    class Node;
+    class TagViewer;
+
+    class ViewTagsDialog : public Dialog
+    {
+        Q_OBJECT
+    public:
+        ViewTagsDialog(Node *p_node, QWidget *p_parent = nullptr);
+
+    protected:
+        void acceptedButtonClicked() Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        void setNode(Node *p_node);
+
+        Node *m_node = nullptr;
+
+        QLabel *m_nodeNameLabel = nullptr;
+
+        TagViewer *m_tagViewer = nullptr;
+    };
+}
+
+#endif // VIEWTAGSDIALOG_H

+ 23 - 2
src/widgets/dockwidgethelper.cpp

@@ -23,6 +23,7 @@
 #include "searchpanel.h"
 #include "snippetpanel.h"
 #include "historypanel.h"
+#include "tagexplorer.h"
 
 using namespace vnotex;
 
@@ -68,6 +69,8 @@ QString DockWidgetHelper::iconFileName(DockIndex p_dockIndex)
         return "outline_dock.svg";
     case DockIndex::HistoryDock:
         return "history_dock.svg";
+    case DockIndex::TagDock:
+        return "tag_dock.svg";
     case DockIndex::SearchDock:
         return "search_dock.svg";
     case DockIndex::SnippetDock:
@@ -95,17 +98,20 @@ void DockWidgetHelper::setupDocks()
     tabifiedDockIndex.append(m_docks.size());
     setupNavigationDock();
 
-    setupOutlineDock();
-
     tabifiedDockIndex.append(m_docks.size());
     setupHistoryDock();
 
+    tabifiedDockIndex.append(m_docks.size());
+    setupTagDock();
+
     tabifiedDockIndex.append(m_docks.size());
     setupSearchDock();
 
     tabifiedDockIndex.append(m_docks.size());
     setupSnippetDock();
 
+    setupOutlineDock();
+
     setupLocationListDock();
 
     setupShortcuts();
@@ -175,6 +181,18 @@ void DockWidgetHelper::setupHistoryDock()
     m_mainWindow->addDockWidget(Qt::LeftDockWidgetArea, dock);
 }
 
+void DockWidgetHelper::setupTagDock()
+{
+    auto dock = createDockWidget(DockIndex::TagDock, tr("Tags"), m_mainWindow);
+
+    dock->setObjectName(QStringLiteral("TagDock.vnotex"));
+    dock->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+    dock->setWidget(m_mainWindow->m_tagExplorer);
+    dock->setFocusProxy(m_mainWindow->m_tagExplorer);
+    m_mainWindow->addDockWidget(Qt::LeftDockWidgetArea, dock);
+}
+
 void DockWidgetHelper::setupLocationListDock()
 {
     auto dock = createDockWidget(DockIndex::LocationListDock, tr("Location List"), m_mainWindow);
@@ -248,6 +266,9 @@ void DockWidgetHelper::setupShortcuts()
     setupDockActivateShortcut(m_docks[DockIndex::HistoryDock],
                               coreConfig.getShortcut(CoreConfig::Shortcut::HistoryDock));
 
+    setupDockActivateShortcut(m_docks[DockIndex::TagDock],
+                              coreConfig.getShortcut(CoreConfig::Shortcut::TagDock));
+
     setupDockActivateShortcut(m_docks[DockIndex::SearchDock],
                               coreConfig.getShortcut(CoreConfig::Shortcut::SearchDock));
     // Extra shortcut for SearchDock.

+ 4 - 1
src/widgets/dockwidgethelper.h

@@ -24,10 +24,11 @@ namespace vnotex
         enum DockIndex
         {
             NavigationDock = 0,
-            OutlineDock,
             HistoryDock,
+            TagDock,
             SearchDock,
             SnippetDock,
+            OutlineDock,
             LocationListDock,
             MaxDock
         };
@@ -102,6 +103,8 @@ namespace vnotex
 
         void setupHistoryDock();
 
+        void setupTagDock();
+
         void setupLocationListDock();
 
         QDockWidget *createDockWidget(DockIndex p_dockIndex, const QString &p_title, QWidget *p_parent);

+ 20 - 26
src/widgets/historypanel.cpp

@@ -6,6 +6,7 @@
 
 #include <utils/widgetutils.h>
 #include <utils/pathutils.h>
+#include <utils/iconutils.h>
 #include <utils/utils.h>
 #include <core/vnotex.h>
 #include <core/exception.h>
@@ -18,26 +19,33 @@
 #include "listwidget.h"
 #include "mainwindow.h"
 #include "messageboxhelper.h"
+#include "navigationmodemgr.h"
 
 using namespace vnotex;
 
 HistoryPanel::HistoryPanel(QWidget *p_parent)
     : QFrame(p_parent)
 {
+    initIcons();
+
     setupUI();
 
     updateSeparators();
 }
 
+void HistoryPanel::initIcons()
+{
+    const auto &themeMgr = VNoteX::getInst().getThemeMgr();
+    m_fileIcon = IconUtils::fetchIcon(themeMgr.getIconFile(QStringLiteral("file_node.svg")));
+}
+
 void HistoryPanel::setupUI()
 {
     auto mainLayout = new QVBoxLayout(this);
     WidgetUtils::setContentsMargins(mainLayout);
 
-    {
-        setupTitleBar(QString(), this);
-        mainLayout->addWidget(m_titleBar);
-    }
+    setupTitleBar(QString(), this);
+    mainLayout->addWidget(m_titleBar);
 
     m_historyList = new ListWidget(true, this);
     m_historyList->setContextMenuPolicy(Qt::CustomContextMenu);
@@ -48,6 +56,9 @@ void HistoryPanel::setupUI()
             this, &HistoryPanel::openItem);
     mainLayout->addWidget(m_historyList);
 
+    m_navigationWrapper.reset(new NavigationModeWrapper<QListWidget, QListWidgetItem>(m_historyList));
+    NavigationModeMgr::getInst().registerNavigationTarget(m_navigationWrapper.data());
+
     setFocusProxy(m_historyList);
 }
 
@@ -132,8 +143,6 @@ bool HistoryPanel::isValidItem(const QListWidgetItem *p_item) const
 
 void HistoryPanel::updateHistoryList()
 {
-    m_pendingUpdate = false;
-
     m_historyList->clear();
 
     const auto &history = HistoryMgr::getInst().getHistory();
@@ -168,19 +177,12 @@ void HistoryPanel::updateHistoryList()
     }
 }
 
-void HistoryPanel::showEvent(QShowEvent *p_event)
+void HistoryPanel::initialize()
 {
-    QFrame::showEvent(p_event);
+    connect(&HistoryMgr::getInst(), &HistoryMgr::historyUpdated,
+            this, &HistoryPanel::updateHistoryList);
 
-    if (!m_initialized) {
-        m_initialized = true;
-        connect(&HistoryMgr::getInst(), &HistoryMgr::historyUpdated,
-                this, &HistoryPanel::updateHistoryListIfProper);
-    }
-
-    if (m_pendingUpdate) {
-        updateHistoryList();
-    }
+    updateHistoryList();
 }
 
 void HistoryPanel::updateSeparators()
@@ -199,21 +201,13 @@ void HistoryPanel::updateSeparators()
     m_separators[2].m_dateUtc = curDateTime.addDays(-7).toUTC();
 }
 
-void HistoryPanel::updateHistoryListIfProper()
-{
-    if (isVisible()) {
-        updateHistoryList();
-    } else {
-        m_pendingUpdate = true;
-    }
-}
-
 void HistoryPanel::addItem(const HistoryItemFull &p_hisItem)
 {
     auto item = new QListWidgetItem(m_historyList);
 
     item->setText(PathUtils::fileNameCheap(p_hisItem.m_item.m_path));
     item->setData(Qt::UserRole, p_hisItem.m_item.m_path);
+    item->setIcon(m_fileIcon);
     if (p_hisItem.m_notebookName.isEmpty()) {
         item->setToolTip(tr("%1\n%2").arg(p_hisItem.m_item.m_path,
                                           Utils::dateTimeString(p_hisItem.m_item.m_lastAccessedTimeUtc.toLocalTime())));

+ 10 - 7
src/widgets/historypanel.h

@@ -3,6 +3,10 @@
 
 #include <QFrame>
 #include <QDateTime>
+#include <QIcon>
+#include <QScopedPointer>
+
+#include "navigationmodewrapper.h"
 
 class QListWidget;
 class QListWidgetItem;
@@ -18,8 +22,7 @@ namespace vnotex
     public:
         explicit HistoryPanel(QWidget *p_parent = nullptr);
 
-    protected:
-        void showEvent(QShowEvent *p_event) Q_DECL_OVERRIDE;
+        void initialize();
 
     private slots:
         void handleContextMenuRequested(const QPoint &p_pos);
@@ -36,14 +39,14 @@ namespace vnotex
             QDateTime m_dateUtc;
         };
 
+        void initIcons();
+
         void setupUI();
 
         void setupTitleBar(const QString &p_title, QWidget *p_parent = nullptr);
 
         void updateHistoryList();
 
-        void updateHistoryListIfProper();
-
         void updateSeparators();
 
         void addItem(const HistoryItemFull &p_hisItem);
@@ -56,11 +59,11 @@ namespace vnotex
 
         QListWidget *m_historyList = nullptr;
 
-        bool m_initialized = false;
-
-        bool m_pendingUpdate = true;
+        QScopedPointer<NavigationModeWrapper<QListWidget, QListWidgetItem>> m_navigationWrapper;
 
         QVector<SeparatorData> m_separators;
+
+        QIcon m_fileIcon;
     };
 }
 

+ 25 - 0
src/widgets/listwidget.cpp

@@ -99,3 +99,28 @@ bool ListWidget::isSeparatorItem(const QListWidgetItem *p_item)
 {
     return p_item->type() == ItemTypeSeparator;
 }
+
+QListWidgetItem *ListWidget::findItem(const QListWidget *p_widget, const QVariant &p_data)
+{
+    QListWidgetItem *item = nullptr;
+    forEachItem(p_widget, [&item, &p_data](QListWidgetItem *itemIter) {
+        if (itemIter->data(Qt::UserRole) == p_data) {
+            item = itemIter;
+            return false;
+        }
+
+        return true;
+    });
+
+    return item;
+}
+
+void ListWidget::forEachItem(const QListWidget *p_widget, const std::function<bool(QListWidgetItem *p_item)> &p_func)
+{
+    int cnt = p_widget->count();
+    for (int i = 0; i < cnt; ++i) {
+        if (!p_func(p_widget->item(i))) {
+            return;
+        }
+    }
+}

+ 8 - 0
src/widgets/listwidget.h

@@ -2,6 +2,9 @@
 #define LISTWIDGET_H
 
 #include <QListWidget>
+
+#include <functional>
+
 #include <QVector>
 
 namespace vnotex
@@ -20,6 +23,11 @@ namespace vnotex
 
         static bool isSeparatorItem(const QListWidgetItem *p_item);
 
+        static QListWidgetItem *findItem(const QListWidget *p_widget, const QVariant &p_data);
+
+        // @p_func: return false to abort the iteration.
+        static void forEachItem(const QListWidget *p_widget, const std::function<bool(QListWidgetItem *p_item)> &p_func);
+
     protected:
         void keyPressEvent(QKeyEvent *p_event) Q_DECL_OVERRIDE;
 

+ 4 - 8
src/widgets/locationlist.cpp

@@ -9,6 +9,7 @@
 #include "widgetsfactory.h"
 #include "titlebar.h"
 #include "styleditemdelegate.h"
+#include "navigationmodemgr.h"
 
 #include <core/vnotex.h>
 #include <core/thememgr.h>
@@ -56,6 +57,9 @@ void LocationList::setupUI()
             });
     mainLayout->addWidget(m_tree);
 
+    m_navigationWrapper.reset(new NavigationModeWrapper<QTreeWidget, QTreeWidgetItem>(m_tree));
+    NavigationModeMgr::getInst().registerNavigationTarget(m_navigationWrapper.data());
+
     setFocusProxy(m_tree);
 }
 
@@ -90,14 +94,6 @@ const QIcon &LocationList::getItemIcon(LocationType p_type)
     }
 }
 
-NavigationModeWrapper<QTreeWidget, QTreeWidgetItem> *LocationList::getNavigationModeWrapper()
-{
-    if (!m_navigationWrapper) {
-        m_navigationWrapper.reset(new NavigationModeWrapper<QTreeWidget, QTreeWidgetItem>(m_tree));
-    }
-    return m_navigationWrapper.data();
-}
-
 void LocationList::setupTitleBar(const QString &p_title, QWidget *p_parent)
 {
     m_titleBar = new TitleBar(p_title, true, TitleBar::Action::None, p_parent);

+ 0 - 2
src/widgets/locationlist.h

@@ -24,8 +24,6 @@ namespace vnotex
 
         explicit LocationList(QWidget *p_parent = nullptr);
 
-        NavigationModeWrapper<QTreeWidget, QTreeWidgetItem> *getNavigationModeWrapper();
-
         void clear();
 
         void addLocation(const ComplexLocation &p_location);

+ 31 - 5
src/widgets/mainwindow.cpp

@@ -53,6 +53,7 @@
 #include <utils/iconutils.h>
 #include <core/thememgr.h>
 #include "dialogs/updater.h"
+#include "tagexplorer.h"
 
 using namespace vnotex;
 
@@ -118,6 +119,8 @@ void MainWindow::kickOffOnStart(const QStringList &p_paths)
 
         checkNotebooksFailedToLoad();
 
+        loadWidgetsData();
+
         demoWidget();
 
         openFiles(p_paths);
@@ -140,6 +143,7 @@ void MainWindow::kickOffOnStart(const QStringList &p_paths)
             if (!file.isEmpty()) {
                 auto paras = QSharedPointer<FileOpenParameters>::create();
                 paras->m_readOnly = true;
+                paras->m_sessionEnabled = false;
                 emit VNoteX::getInst().openFileRequested(file, paras);
             }
         }
@@ -247,7 +251,9 @@ void MainWindow::setupCentralWidget()
 
 void MainWindow::setupDocks()
 {
-    setupNotebookExplorer(this);
+    setupNotebookExplorer();
+
+    setupTagExplorer();
 
     setupOutlineViewer();
 
@@ -298,13 +304,11 @@ void MainWindow::setupLocationList()
 {
     m_locationList = new LocationList(this);
     m_locationList->setObjectName("LocationList.vnotex");
-
-    NavigationModeMgr::getInst().registerNavigationTarget(m_locationList->getNavigationModeWrapper());
 }
 
-void MainWindow::setupNotebookExplorer(QWidget *p_parent)
+void MainWindow::setupNotebookExplorer()
 {
-    m_notebookExplorer = new NotebookExplorer(p_parent);
+    m_notebookExplorer = new NotebookExplorer(this);
     connect(&VNoteX::getInst(), &VNoteX::newNotebookRequested,
             m_notebookExplorer, &NotebookExplorer::newNotebook);
     connect(&VNoteX::getInst(), &VNoteX::newNotebookFromFolderRequested,
@@ -387,6 +391,8 @@ void MainWindow::closeEvent(QCloseEvent *p_event)
             return;
         }
 
+        m_trayIcon->hide();
+
         QMainWindow::closeEvent(p_event);
         qApp->exit(exitCode > -1 ? exitCode : 0);
     } else {
@@ -408,6 +414,7 @@ void MainWindow::saveStateAndGeometry()
     sg.m_mainState = saveState();
     sg.m_mainGeometry = saveGeometry();
     sg.m_visibleDocksBeforeExpand = m_visibleDocksBeforeExpand;
+    sg.m_tagExplorerState = m_tagExplorer->saveState();
 
     auto& sessionConfig = ConfigMgr::getInst().getSessionConfig();
     sessionConfig.setMainWindowStateGeometry(sg);
@@ -434,6 +441,10 @@ void MainWindow::loadStateAndGeometry(bool p_stateOnly)
             m_visibleDocksBeforeExpand = m_dockWidgetHelper.getVisibleDocks();
         }
     }
+
+    if (!sg.m_tagExplorerState.isEmpty()) {
+        m_tagExplorer->restoreState(sg.m_tagExplorerState);
+    }
 }
 
 void MainWindow::resetStateAndGeometry()
@@ -484,6 +495,7 @@ void MainWindow::setupOutlineViewer()
     m_outlineViewer = new OutlineViewer(QString(), this);
     m_outlineViewer->setObjectName("OutlineViewer.vnotex");
 
+    // There are OutlineViewers in each ViewWindow. We only need to register navigation mode for the outline panel.
     NavigationModeMgr::getInst().registerNavigationTarget(m_outlineViewer->getNavigationModeWrapper());
 
     connect(m_viewArea, &ViewArea::currentViewWindowChanged,
@@ -738,3 +750,17 @@ void MainWindow::checkNotebooksFailedToLoad()
         notebookMgr.clearNotebooksFailedToLoad();
     }
 }
+
+void MainWindow::setupTagExplorer()
+{
+    m_tagExplorer = new TagExplorer(this);
+    connect(&VNoteX::getInst().getNotebookMgr(), &NotebookMgr::currentNotebookChanged,
+            m_tagExplorer, &TagExplorer::setNotebook);
+}
+
+void MainWindow::loadWidgetsData()
+{
+    m_historyPanel->initialize();
+
+    m_snippetPanel->initialize();
+}

+ 8 - 1
src/widgets/mainwindow.h

@@ -19,6 +19,7 @@ namespace vnotex
 {
     class ToolBox;
     class NotebookExplorer;
+    class TagExplorer;
     class ViewArea;
     class Event;
     class OutlineViewer;
@@ -110,7 +111,9 @@ namespace vnotex
 
         void setupHistoryPanel();
 
-        void setupNotebookExplorer(QWidget *p_parent = nullptr);
+        void setupNotebookExplorer();
+
+        void setupTagExplorer();
 
         void setupDocks();
 
@@ -143,6 +146,8 @@ namespace vnotex
 
         void checkNotebooksFailedToLoad();
 
+        void loadWidgetsData();
+
         ToolBarHelper m_toolBarHelper;
 
         StatusBarHelper m_statusBarHelper;
@@ -153,6 +158,8 @@ namespace vnotex
 
         NotebookExplorer *m_notebookExplorer = nullptr;
 
+        TagExplorer *m_tagExplorer = nullptr;
+
         ViewArea *m_viewArea = nullptr;
 
         QWidget *m_viewAreaStatusWidget = nullptr;

+ 9 - 4
src/widgets/markdownviewwindow.cpp

@@ -260,6 +260,8 @@ void MarkdownViewWindow::setupToolBar()
 
     addAction(toolBar, ViewWindowToolBarHelper::Attachment);
 
+    addAction(toolBar, ViewWindowToolBarHelper::Tag);
+
     toolBar->addSeparator();
 
     addAction(toolBar, ViewWindowToolBarHelper::SectionNumber);
@@ -1115,18 +1117,21 @@ QPoint MarkdownViewWindow::getFloatingWidgetPosition()
 QString MarkdownViewWindow::selectedText() const
 {
     switch (m_mode) {
-    case ViewWindowMode::FullPreview:
-    case ViewWindowMode::Invalid:
-        Q_FALLTHROUGH();
     case ViewWindowMode::Read:
         Q_ASSERT(m_viewer);
         return m_viewer->selectedText();
+
     case ViewWindowMode::Edit:
+        Q_FALLTHROUGH();
+    case ViewWindowMode::FullPreview:
+        Q_FALLTHROUGH();
     case ViewWindowMode::FocusPreview:
         Q_ASSERT(m_editor);
         return m_editor->getTextEdit()->selectedText();
+
+    default:
+        return QString();
     }
-    return QString("");
 }
 
 void MarkdownViewWindow::handleImageHostChanged(const QString &p_hostName)

+ 1 - 0
src/widgets/navigationmodemgr.h

@@ -18,6 +18,7 @@ namespace vnotex
     public:
         ~NavigationModeMgr();
 
+        // Maybe we need a unregisterNavigationTarget()?
         void registerNavigationTarget(NavigationMode *p_target);
 
         static NavigationModeMgr &getInst();

+ 17 - 11
src/widgets/notebookexplorer.cpp

@@ -16,10 +16,10 @@
 #include "dialogs/importnotebookdialog.h"
 #include "dialogs/importfolderdialog.h"
 #include "dialogs/importlegacynotebookdialog.h"
-#include "vnotex.h"
+#include <core/vnotex.h>
 #include "mainwindow.h"
-#include "notebook/notebook.h"
-#include "notebookmgr.h"
+#include <notebook/notebook.h>
+#include <core/notebookmgr.h>
 #include <utils/iconutils.h>
 #include <utils/widgetutils.h>
 #include <utils/pathutils.h>
@@ -144,8 +144,8 @@ TitleBar *NotebookExplorer::setupTitleBar(QWidget *p_parent)
                         return;
                     }
                     int ret = MessageBoxHelper::questionOkCancel(MessageBoxHelper::Warning,
-                                                                 tr("Scan the whole notebook (%1) and import external files automatically.").arg(m_currentNotebook->getName()),
-                                                                 tr("This operation helps importing external files that are added outside VNote. "
+                                                                 tr("Scan the whole notebook (%1) and import external files automatically?").arg(m_currentNotebook->getName()),
+                                                                 tr("This operation helps importing external files that are added outside from VNote. "
                                                                     "It may import unexpected files."),
                                                                  tr("It is recommended to always manage files within VNote."),
                                                                  VNoteX::getInst().getMainWindow());
@@ -220,8 +220,6 @@ void NotebookExplorer::loadNotebooks()
     auto &notebookMgr = VNoteX::getInst().getNotebookMgr();
     const auto &notebooks = notebookMgr.getNotebooks();
     m_selector->setNotebooks(notebooks);
-
-    emit updateTitleBarMenuActions();
 }
 
 void NotebookExplorer::reloadNotebook(const Notebook *p_notebook)
@@ -241,8 +239,6 @@ void NotebookExplorer::setCurrentNotebook(const QSharedPointer<Notebook> &p_note
     m_nodeExplorer->setNotebook(p_notebook);
 
     recoverSession();
-
-    emit updateTitleBarMenuActions();
 }
 
 void NotebookExplorer::newNotebook()
@@ -546,6 +542,14 @@ void NotebookExplorer::recoverSession()
 void NotebookExplorer::rebuildDatabase()
 {
     if (m_currentNotebook) {
+        int okRet = MessageBoxHelper::questionOkCancel(MessageBoxHelper::Warning,
+            tr("Rebuild the database of notebook (%1)?").arg(m_currentNotebook->getName()),
+            tr("This operation will rebuild the notebook database from configuration files. It may take time."),
+            tr("A notebook may use a database for cache, such as IDs of nodes and tags."),
+            VNoteX::getInst().getMainWindow());
+        if (okRet != QMessageBox::Ok) {
+            return;
+        }
 
         QProgressDialog proDlg(tr("Rebuilding notebook database..."),
                                QString(),
@@ -563,10 +567,12 @@ void NotebookExplorer::rebuildDatabase()
 
         if (ret) {
             MessageBoxHelper::notify(MessageBoxHelper::Type::Information,
-                                     tr("Notebook database has been rebuilt."));
+                                     tr("Notebook database has been rebuilt."),
+                                     VNoteX::getInst().getMainWindow());
         } else {
             MessageBoxHelper::notify(MessageBoxHelper::Type::Warning,
-                                     tr("Failed to rebuild notebook database."));
+                                     tr("Failed to rebuild notebook database."),
+                                     VNoteX::getInst().getMainWindow());
         }
     }
 }

+ 0 - 3
src/widgets/notebookexplorer.h

@@ -60,9 +60,6 @@ namespace vnotex
     signals:
         void notebookActivated(ID p_notebookId);
 
-        // Internal use only.
-        void updateTitleBarMenuActions();
-
     private:
         void setupUI();
 

+ 125 - 117
src/widgets/notebooknodeexplorer.cpp

@@ -23,6 +23,7 @@
 #include "dialogs/folderpropertiesdialog.h"
 #include "dialogs/deleteconfirmdialog.h"
 #include "dialogs/sortdialog.h"
+#include "dialogs/viewtagsdialog.h"
 #include <utils/widgetutils.h>
 #include <utils/pathutils.h>
 #include <utils/clipboardutils.h>
@@ -39,19 +40,7 @@
 
 using namespace vnotex;
 
-QIcon NotebookNodeExplorer::s_folderNodeIcon;
-
-QIcon NotebookNodeExplorer::s_fileNodeIcon;
-
-QIcon NotebookNodeExplorer::s_invalidFolderNodeIcon;
-
-QIcon NotebookNodeExplorer::s_invalidFileNodeIcon;
-
-QIcon NotebookNodeExplorer::s_recycleBinNodeIcon;
-
-QIcon NotebookNodeExplorer::s_externalFolderNodeIcon;
-
-QIcon NotebookNodeExplorer::s_externalFileNodeIcon;
+QIcon NotebookNodeExplorer::s_nodeIcons[NodeIcon::MaxIcons];
 
 NotebookNodeExplorer::NodeData::NodeData()
 {
@@ -188,7 +177,7 @@ NotebookNodeExplorer::NotebookNodeExplorer(QWidget *p_parent)
 
 void NotebookNodeExplorer::initNodeIcons() const
 {
-    if (!s_folderNodeIcon.isNull()) {
+    if (!s_nodeIcons[0].isNull()) {
         return;
     }
 
@@ -205,13 +194,13 @@ void NotebookNodeExplorer::initNodeIcons() const
     const QString fileIconName("file_node.svg");
     const QString recycleBinIconName("recycle_bin.svg");
 
-    s_folderNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), fg);
-    s_fileNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), fg);
-    s_invalidFolderNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), invalidFg);
-    s_invalidFileNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), invalidFg);
-    s_recycleBinNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(recycleBinIconName), fg);
-    s_externalFolderNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), externalFg);
-    s_externalFileNodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), externalFg);
+    s_nodeIcons[NodeIcon::FolderNode] = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), fg);
+    s_nodeIcons[NodeIcon::FileNode] = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), fg);
+    s_nodeIcons[NodeIcon::InvalidFolderNode] = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), invalidFg);
+    s_nodeIcons[NodeIcon::InvalidFileNode] = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), invalidFg);
+    s_nodeIcons[NodeIcon::RecycleBinNode] = IconUtils::fetchIcon(themeMgr.getIconFile(recycleBinIconName), fg);
+    s_nodeIcons[NodeIcon::ExternalFolderNode] = IconUtils::fetchIcon(themeMgr.getIconFile(folderIconName), externalFg);
+    s_nodeIcons[NodeIcon::ExternalFileNode] = IconUtils::fetchIcon(themeMgr.getIconFile(fileIconName), externalFg);
 }
 
 void NotebookNodeExplorer::setupUI()
@@ -497,7 +486,7 @@ void NotebookNodeExplorer::fillTreeItem(QTreeWidgetItem *p_item, Node *p_node, b
     setItemNodeData(p_item, NodeData(p_node, p_loaded));
     p_item->setText(Column::Name, p_node->getName());
     p_item->setIcon(Column::Name, getNodeItemIcon(p_node));
-    p_item->setToolTip(Column::Name, p_node->exists() ? p_node->getName() : (tr("[Invalid] %1").arg(p_node->getName())));
+    p_item->setToolTip(Column::Name, generateToolTip(p_node));
 }
 
 void NotebookNodeExplorer::fillTreeItem(QTreeWidgetItem *p_item, const QSharedPointer<ExternalNode> &p_node) const
@@ -511,19 +500,19 @@ void NotebookNodeExplorer::fillTreeItem(QTreeWidgetItem *p_item, const QSharedPo
 const QIcon &NotebookNodeExplorer::getNodeItemIcon(const Node *p_node) const
 {
     if (p_node->hasContent()) {
-        return p_node->exists() ? s_fileNodeIcon : s_invalidFileNodeIcon;
+        return p_node->exists() ? s_nodeIcons[NodeIcon::FileNode] : s_nodeIcons[NodeIcon::InvalidFileNode];
     } else {
         if (p_node->getUse() == Node::Use::RecycleBin) {
-            return s_recycleBinNodeIcon;
+            return s_nodeIcons[NodeIcon::RecycleBinNode];
         }
 
-        return p_node->exists() ? s_folderNodeIcon : s_invalidFolderNodeIcon;
+        return p_node->exists() ? s_nodeIcons[NodeIcon::FolderNode] : s_nodeIcons[NodeIcon::InvalidFolderNode];
     }
 }
 
 const QIcon &NotebookNodeExplorer::getNodeItemIcon(const ExternalNode *p_node) const
 {
-    return p_node->isFolder() ? s_externalFolderNodeIcon : s_externalFileNodeIcon;
+    return p_node->isFolder() ? s_nodeIcons[NodeIcon::ExternalFolderNode] : s_nodeIcons[NodeIcon::ExternalFileNode];
 }
 
 Node *NotebookNodeExplorer::getCurrentNode() const
@@ -752,158 +741,126 @@ void NotebookNodeExplorer::clearStateCache(const Notebook *p_notebook)
 
 void NotebookNodeExplorer::createContextMenuOnRoot(QMenu *p_menu)
 {
-    auto act = createAction(Action::NewNote, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::NewNote, p_menu);
 
-    act = createAction(Action::NewFolder, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::NewFolder, p_menu);
 
     if (isPasteOnNodeAvailable(nullptr)) {
         p_menu->addSeparator();
-        act = createAction(Action::Paste, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Paste, p_menu);
     }
 
     p_menu->addSeparator();
 
-    act = createAction(Action::Reload, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::Reload, p_menu);
 
-    act = createAction(Action::ReloadIndex, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::ReloadIndex, p_menu);
 
-    act = createAction(Action::OpenLocation, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::OpenLocation, p_menu);
 }
 
 void NotebookNodeExplorer::createContextMenuOnNode(QMenu *p_menu, const Node *p_node)
 {
     const int selectedSize = m_masterExplorer->selectedItems().size();
-    QAction *act = nullptr;
-
     if (m_notebook->isRecycleBinNode(p_node)) {
         // Recycle bin node.
-        act = createAction(Action::Reload, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Reload, p_menu);
 
-        act = createAction(Action::ReloadIndex, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::ReloadIndex, p_menu);
 
         if (selectedSize == 1) {
-            act = createAction(Action::EmptyRecycleBin, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::EmptyRecycleBin, p_menu);
 
-            act = createAction(Action::OpenLocation, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::OpenLocation, p_menu);
         }
     } else if (m_notebook->isNodeInRecycleBin(p_node)) {
         // Node in recycle bin.
-        act = createAction(Action::Open, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Open, p_menu);
 
         addOpenWithMenu(p_menu);
 
         p_menu->addSeparator();
 
         if (selectedSize == 1 && p_node->isContainer()) {
-            act = createAction(Action::ExpandAll, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::ExpandAll, p_menu);
         }
 
         p_menu->addSeparator();
 
-        act = createAction(Action::Cut, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Cut, p_menu);
 
-        act = createAction(Action::DeleteFromRecycleBin, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::DeleteFromRecycleBin, p_menu);
 
         p_menu->addSeparator();
 
-        act = createAction(Action::Reload, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Reload, p_menu);
 
-        act = createAction(Action::ReloadIndex, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::ReloadIndex, p_menu);
 
         if (selectedSize == 1) {
             p_menu->addSeparator();
 
-            act = createAction(Action::CopyPath, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::CopyPath, p_menu);
 
-            act = createAction(Action::OpenLocation, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::OpenLocation, p_menu);
         }
     } else {
-        act = createAction(Action::Open, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Open, p_menu);
 
         addOpenWithMenu(p_menu);
 
         p_menu->addSeparator();
 
         if (selectedSize == 1 && p_node->isContainer()) {
-            act = createAction(Action::ExpandAll, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::ExpandAll, p_menu);
         }
 
         p_menu->addSeparator();
 
-        act = createAction(Action::NewNote, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::NewNote, p_menu);
 
-        act = createAction(Action::NewFolder, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::NewFolder, p_menu);
 
         p_menu->addSeparator();
 
-        act = createAction(Action::Copy, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Copy, p_menu);
 
-        act = createAction(Action::Cut, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Cut, p_menu);
 
         if (selectedSize == 1 && isPasteOnNodeAvailable(p_node)) {
-            act = createAction(Action::Paste, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::Paste, p_menu);
         }
 
-        act = createAction(Action::Delete, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Delete, p_menu);
 
-        act = createAction(Action::RemoveFromConfig, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::RemoveFromConfig, p_menu);
 
         p_menu->addSeparator();
 
-        act = createAction(Action::Reload, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Reload, p_menu);
 
-        act = createAction(Action::ReloadIndex, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::ReloadIndex, p_menu);
 
-        act = createAction(Action::Sort, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::Sort, p_menu);
 
-        {
+        if (selectedSize == 1
+            && m_notebook->tag()
+            && !p_node->isContainer()) {
             p_menu->addSeparator();
 
-            act = createAction(Action::PinToQuickAccess, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::Tag, p_menu);
         }
 
-        if (selectedSize == 1) {
-            p_menu->addSeparator();
+        p_menu->addSeparator();
 
-            act = createAction(Action::CopyPath, p_menu);
-            p_menu->addAction(act);
+        createAndAddAction(Action::PinToQuickAccess, p_menu);
 
-            act = createAction(Action::OpenLocation, p_menu);
-            p_menu->addAction(act);
+        if (selectedSize == 1) {
+            createAndAddAction(Action::CopyPath, p_menu);
+
+            createAndAddAction(Action::OpenLocation, p_menu);
 
-            act = createAction(Action::Properties, p_menu);
-            p_menu->addAction(act);
+            createAndAddAction(Action::Properties, p_menu);
         }
     }
 }
@@ -913,33 +870,22 @@ void NotebookNodeExplorer::createContextMenuOnExternalNode(QMenu *p_menu, const
     Q_UNUSED(p_node);
 
     const int selectedSize = m_masterExplorer->selectedItems().size();
-    QAction *act = nullptr;
-
-    act = createAction(Action::Open, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::Open, p_menu);
 
     addOpenWithMenu(p_menu);
 
     p_menu->addSeparator();
 
-    act = createAction(Action::ImportToConfig, p_menu);
-    p_menu->addAction(act);
+    createAndAddAction(Action::ImportToConfig, p_menu);
 
-    {
-        p_menu->addSeparator();
+    p_menu->addSeparator();
 
-        act = createAction(Action::PinToQuickAccess, p_menu);
-        p_menu->addAction(act);
-    }
+    createAndAddAction(Action::PinToQuickAccess, p_menu);
 
     if (selectedSize == 1) {
-        p_menu->addSeparator();
-
-        act = createAction(Action::CopyPath, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::CopyPath, p_menu);
 
-        act = createAction(Action::OpenLocation, p_menu);
-        p_menu->addAction(act);
+        createAndAddAction(Action::OpenLocation, p_menu);
     }
 }
 
@@ -1191,31 +1137,73 @@ QAction *NotebookNodeExplorer::createAction(Action p_act, QObject *p_parent)
         break;
 
     case Action::PinToQuickAccess:
-        act = new QAction(tr("Pin To &Quick Access"), p_parent);
+        act = new QAction(generateMenuActionIcon(QStringLiteral("quick_access_menu.svg")),
+                          tr("Pin To &Quick Access"),
+                          p_parent);
         connect(act, &QAction::triggered,
                 this, [this]() {
                     auto nodes = getSelectedNodes();
                     QStringList files;
+                    bool hasFilteredAway = false;
                     for (const auto &node : nodes.first) {
                         if (node->hasContent()) {
                             files.push_back(node->fetchAbsolutePath());
+                        } else {
+                            hasFilteredAway = true;
                         }
                     }
                     for (const auto &node : nodes.second) {
                         if (!node->isFolder()) {
                             files.push_back(node->fetchAbsolutePath());
+                        } else {
+                            hasFilteredAway = true;
                         }
                     }
                     if (!files.isEmpty()) {
                         emit VNoteX::getInst().pinToQuickAccessRequested(files);
                     }
+                    if (hasFilteredAway) {
+                        VNoteX::getInst().showStatusMessageShort(tr("Folder is not supported by quick access"));
+                    }
+                });
+        break;
+
+    case Action::Tag:
+        act = new QAction(generateMenuActionIcon(QStringLiteral("tag.svg")), tr("&Tags"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this]() {
+                    auto item = m_masterExplorer->currentItem();
+                    if (!item || !m_notebook->tag()) {
+                        return;
+                    }
+                    auto data = getItemNodeData(item);
+                    if (data.isNode()) {
+                        auto node = data.getNode();
+                        if (checkInvalidNode(node)) {
+                            return;
+                        }
+
+                        ViewTagsDialog dialog(node, VNoteX::getInst().getMainWindow());
+                        dialog.exec();
+                    }
                 });
         break;
+
+    default:
+        Q_ASSERT(false);
+        break;
     }
 
     return act;
 }
 
+QAction *NotebookNodeExplorer::createAndAddAction(Action p_act, QMenu *p_menu)
+{
+    auto act = createAction(p_act, p_menu);
+    p_menu->addAction(act);
+    return act;
+}
+
 void NotebookNodeExplorer::copySelectedNodes(bool p_move)
 {
     auto nodes = getSelectedNodes().first;
@@ -2082,3 +2070,23 @@ void NotebookNodeExplorer::loadItemChildren(QTreeWidgetItem *p_item) const
         }
     }
 }
+
+QString NotebookNodeExplorer::generateToolTip(const Node *p_node)
+{
+    Q_ASSERT(p_node->isLoaded());
+    QString tip = p_node->exists() ? p_node->getName() : (tr("[Invalid] %1").arg(p_node->getName()));
+    tip += QLatin1String("\n\n");
+
+    if (!p_node->getTags().isEmpty()) {
+        const auto &tags = p_node->getTags();
+        QString tagString = tags.first();
+        for (int i = 1; i < tags.size(); ++i) {
+            tagString += QLatin1String("; ") + tags[i];
+        }
+        tip += tr("Tags: %1\n").arg(tagString);
+    }
+
+    tip += tr("Created Time: %1\n").arg(Utils::dateTimeString(p_node->getCreatedTimeUtc().toLocalTime()));
+    tip += tr("Modified Time: %1").arg(Utils::dateTimeString(p_node->getModifiedTimeUtc().toLocalTime()));
+    return tip;
+}

+ 18 - 13
src/widgets/notebooknodeexplorer.h

@@ -115,6 +115,8 @@ namespace vnotex
 
         Node *currentExploredNode() const;
 
+        static QString generateToolTip(const Node *p_node);
+
     signals:
         void nodeActivated(Node *p_node, const QSharedPointer<FileOpenParameters> &p_paras);
 
@@ -154,7 +156,8 @@ namespace vnotex
             ImportToConfig,
             Open,
             ExpandAll,
-            PinToQuickAccess
+            PinToQuickAccess,
+            Tag
         };
 
         void setupUI();
@@ -214,6 +217,8 @@ namespace vnotex
         // Factory function to create action.
         QAction *createAction(Action p_act, QObject *p_parent);
 
+        QAction *createAndAddAction(Action p_act, QMenu *p_menu);
+
         void copySelectedNodes(bool p_move);
 
         void pasteNodesFromClipboard();
@@ -301,19 +306,19 @@ namespace vnotex
 
         bool m_autoImportExternalFiles = true;
 
-        static QIcon s_folderNodeIcon;
-
-        static QIcon s_fileNodeIcon;
-
-        static QIcon s_invalidFolderNodeIcon;
-
-        static QIcon s_invalidFileNodeIcon;
-
-        static QIcon s_recycleBinNodeIcon;
-
-        static QIcon s_externalFolderNodeIcon;
+        enum NodeIcon
+        {
+            FolderNode = 0,
+            FileNode,
+            InvalidFolderNode,
+            InvalidFileNode,
+            RecycleBinNode,
+            ExternalFolderNode,
+            ExternalFileNode,
+            MaxIcons
+        };
 
-        static QIcon s_externalFileNodeIcon;
+        static QIcon s_nodeIcons[NodeIcon::MaxIcons];
     };
 }
 

+ 5 - 2
src/widgets/outlinepopup.cpp

@@ -14,6 +14,11 @@ OutlinePopup::OutlinePopup(QToolButton *p_btn, QWidget *p_parent)
       m_button(p_btn)
 {
     setupUI();
+
+    connect(this, &QMenu::aboutToShow,
+            this, [this]() {
+                m_viewer->setFocus();
+            });
 }
 
 void OutlinePopup::setupUI()
@@ -36,8 +41,6 @@ void OutlinePopup::showEvent(QShowEvent* p_event)
 {
     QMenu::showEvent(p_event);
 
-    m_viewer->setFocus();
-
     // Move it to be right-aligned.
     if (m_button->isVisible()) {
         const auto p = pos();

+ 5 - 8
src/widgets/quickselector.cpp

@@ -1,8 +1,6 @@
 #include "quickselector.h"
 
 #include <QVBoxLayout>
-#include <QWidgetAction>
-#include <QMenu>
 #include <QLabel>
 #include <QListWidgetItem>
 #include <QKeyEvent>
@@ -72,10 +70,14 @@ void QuickSelector::setupUI(const QString &p_title)
         mainLayout->addWidget(new QLabel(p_title, this));
     }
 
-    m_searchLineEdit = dynamic_cast<LineEdit *>(WidgetsFactory::createLineEdit(this));
+    m_searchLineEdit = static_cast<LineEdit *>(WidgetsFactory::createLineEdit(this));
     m_searchLineEdit->setInputMethodEnabled(false);
     connect(m_searchLineEdit, &QLineEdit::textEdited,
             this, &QuickSelector::searchAndFilter);
+    connect(m_searchLineEdit, &QLineEdit::returnPressed,
+            this, [this]() {
+                activateItem(m_itemList->currentItem());
+            });
     mainLayout->addWidget(m_searchLineEdit);
 
     setFocusProxy(m_searchLineEdit);
@@ -154,11 +156,6 @@ bool QuickSelector::eventFilter(QObject *p_obj, QEvent *p_event)
                 m_searchLineEdit->setFocus();
             }
             return true;
-        } else if (key == Qt::Key_Enter || key == Qt::Key_Return) {
-            if (p_obj == m_searchLineEdit) {
-                activateItem(m_itemList->currentItem());
-                return true;
-            }
         }
     }
     return FloatingWidget::eventFilter(p_obj, p_event);

+ 5 - 10
src/widgets/snippetpanel.cpp

@@ -50,6 +50,11 @@ void SnippetPanel::setupUI()
     setFocusProxy(m_snippetList);
 }
 
+void SnippetPanel::initialize()
+{
+    updateSnippetList();
+}
+
 void SnippetPanel::setupTitleBar(const QString &p_title, QWidget *p_parent)
 {
     m_titleBar = new TitleBar(p_title, true, TitleBar::Action::Menu, p_parent);
@@ -125,16 +130,6 @@ void SnippetPanel::updateSnippetList()
     updateItemsCountLabel();
 }
 
-void SnippetPanel::showEvent(QShowEvent *p_event)
-{
-    QFrame::showEvent(p_event);
-
-    if (!m_listInitialized) {
-        m_listInitialized = true;
-        updateSnippetList();
-    }
-}
-
 void SnippetPanel::handleContextMenuRequested(const QPoint &p_pos)
 {
     auto item = m_snippetList->itemAt(p_pos);

+ 2 - 5
src/widgets/snippetpanel.h

@@ -16,12 +16,11 @@ namespace vnotex
     public:
         explicit SnippetPanel(QWidget *p_parent = nullptr);
 
+        void initialize();
+
     signals:
         void applySnippetRequested(const QString &p_name);
 
-    protected:
-        void showEvent(QShowEvent *p_event) Q_DECL_OVERRIDE;
-
     private slots:
         void newSnippet();
 
@@ -48,8 +47,6 @@ namespace vnotex
 
         QListWidget *m_snippetList = nullptr;
 
-        bool m_listInitialized = false;
-
         bool m_builtInSnippetsVisible = true;
     };
 }

+ 3 - 1
src/widgets/styleditemdelegate.h

@@ -18,7 +18,9 @@ namespace vnotex
 
     enum
     {
-        HighlightsRole = 0x0101
+        // Qt::UserRole = 0x0100
+        UserRole2 = 0x0101,
+        HighlightsRole = 0x0102
     };
 
 

+ 423 - 0
src/widgets/tagexplorer.cpp

@@ -0,0 +1,423 @@
+#include "tagexplorer.h"
+
+#include <QVBoxLayout>
+#include <QToolButton>
+#include <QMenu>
+#include <QListWidgetItem>
+#include <QTreeWidgetItem>
+#include <QSplitter>
+#include <QDebug>
+#include <QTimer>
+#include <QAbstractItemModel>
+
+#include "titlebar.h"
+
+#include <utils/widgetutils.h>
+#include <utils/iconutils.h>
+#include <core/vnotex.h>
+#include <core/notebookmgr.h>
+#include <notebook/notebook.h>
+#include <notebook/tagi.h>
+#include <core/widgetconfig.h>
+#include <core/configmgr.h>
+#include <core/fileopenparameters.h>
+
+#include "widgetsfactory.h"
+#include "listwidget.h"
+#include "treewidget.h"
+#include "navigationmodemgr.h"
+#include "notebooknodeexplorer.h"
+#include "mainwindow.h"
+#include "messageboxhelper.h"
+#include "dialogs/newtagdialog.h"
+#include "dialogs/renametagdialog.h"
+
+using namespace vnotex;
+
+TagExplorer::TagExplorer(QWidget *p_parent)
+    : QFrame(p_parent)
+{
+    initIcons();
+
+    setupUI();
+}
+
+void TagExplorer::initIcons()
+{
+    const auto &themeMgr = VNoteX::getInst().getThemeMgr();
+    m_tagIcon = IconUtils::fetchIcon(themeMgr.getIconFile(QStringLiteral("tag.svg")));
+    m_nodeIcon = IconUtils::fetchIcon(themeMgr.getIconFile(QStringLiteral("file_node.svg")));
+}
+
+void TagExplorer::setupUI()
+{
+    auto mainLayout = new QVBoxLayout(this);
+    WidgetUtils::setContentsMargins(mainLayout);
+
+    setupTitleBar(this);
+    mainLayout->addWidget(m_titleBar);
+
+    m_splitter = new QSplitter(this);
+    mainLayout->addWidget(m_splitter);
+
+    setTwoColumnsEnabled(ConfigMgr::getInst().getWidgetConfig().getTagExplorerTwoColumnsEnabled());
+
+    setupTagTree(m_splitter);
+    m_splitter->addWidget(m_tagTree);
+
+    setupNodeList(m_splitter);
+    m_splitter->addWidget(m_nodeList);
+
+    setFocusProxy(m_tagTree);
+}
+
+void TagExplorer::setupTitleBar(QWidget *p_parent)
+{
+    m_titleBar = new TitleBar(QString(), false, TitleBar::Action::Menu, p_parent);
+    m_titleBar->setActionButtonsAlwaysShown(true);
+
+    auto twoColumnsAct = m_titleBar->addMenuAction(tr("Two Columns"),
+                                                   m_titleBar,
+                                                   [this](bool p_checked) {
+                                                       ConfigMgr::getInst().getWidgetConfig().setTagExplorerTwoColumnsEnabled(p_checked);
+                                                       setTwoColumnsEnabled(p_checked);
+                                                   });
+    twoColumnsAct->setCheckable(true);
+    twoColumnsAct->setChecked(ConfigMgr::getInst().getWidgetConfig().getTagExplorerTwoColumnsEnabled());
+}
+
+void TagExplorer::setTwoColumnsEnabled(bool p_enabled)
+{
+    if (m_splitter) {
+        m_splitter->setOrientation(p_enabled ? Qt::Horizontal : Qt::Vertical);
+    }
+}
+
+void TagExplorer::setupTagTree(QWidget *p_parent)
+{
+    auto timer = new QTimer(this);
+    timer->setSingleShot(true);
+    timer->setInterval(500);
+    connect(timer, &QTimer::timeout,
+            this, &TagExplorer::activateTagItem);
+
+    m_tagTree = new TreeWidget(TreeWidget::ClickSpaceToClearSelection, p_parent);
+    TreeWidget::setupSingleColumnHeaderlessTree(m_tagTree, true, false);
+    TreeWidget::showHorizontalScrollbar(m_tagTree);
+    m_tagTree->setDragDropMode(QAbstractItemView::InternalMove);
+    connect(m_tagTree, &QTreeWidget::currentItemChanged,
+            timer, QOverload<void>::of(&QTimer::start));
+    connect(m_tagTree, &QTreeWidget::itemClicked,
+            timer, QOverload<void>::of(&QTimer::start));
+    connect(m_tagTree, &QTreeWidget::customContextMenuRequested,
+            this, &TagExplorer::handleTagTreeContextMenuRequested);
+    connect(m_tagTree, &TreeWidget::itemMoved,
+            this, &TagExplorer::handleTagMoved);
+
+    m_tagTreeNavigationWrapper.reset(new NavigationModeWrapper<QTreeWidget, QTreeWidgetItem>(m_tagTree));
+    NavigationModeMgr::getInst().registerNavigationTarget(m_tagTreeNavigationWrapper.data());
+}
+
+void TagExplorer::setupNodeList(QWidget *p_parent)
+{
+    m_nodeList = new ListWidget(p_parent);
+    m_nodeList->setContextMenuPolicy(Qt::CustomContextMenu);
+    m_nodeList->setSelectionMode(QAbstractItemView::ExtendedSelection);
+    connect(m_nodeList, &QListWidget::customContextMenuRequested,
+            this, &TagExplorer::handleNodeListContextMenuRequested);
+    connect(m_nodeList, &QListWidget::itemActivated,
+            this, &TagExplorer::openItem);
+
+    m_nodeListNavigationWrapper.reset(new NavigationModeWrapper<QListWidget, QListWidgetItem>(m_nodeList));
+    NavigationModeMgr::getInst().registerNavigationTarget(m_nodeListNavigationWrapper.data());
+}
+
+QByteArray TagExplorer::saveState() const
+{
+    return m_splitter->saveState();
+}
+
+void TagExplorer::restoreState(const QByteArray &p_data)
+{
+    m_splitter->restoreState(p_data);
+}
+
+void TagExplorer::setNotebook(const QSharedPointer<Notebook> &p_notebook)
+{
+    if (m_notebook == p_notebook) {
+        return;
+    }
+
+    if (m_notebook) {
+        disconnect(m_notebook.data(), nullptr, this, nullptr);
+    }
+
+    m_notebook = p_notebook;
+    if (m_notebook) {
+        connect(m_notebook.data(), &Notebook::tagsUpdated,
+                this, &TagExplorer::updateTags);
+    }
+
+    m_lastTagName.clear();
+
+    updateTags();
+}
+
+void TagExplorer::updateTags()
+{
+    m_tagTree->clear();
+
+    auto tagI = m_notebook ? m_notebook->tag() : nullptr;
+    if (!tagI) {
+        return;
+    }
+
+    const auto &topLevelTags = tagI->getTopLevelTags();
+    for (const auto &tag : topLevelTags) {
+        auto item = new QTreeWidgetItem(m_tagTree);
+        fillTagItem(tag, item);
+        loadTagChildren(tag, item);
+    }
+
+    m_tagTree->expandAll();
+
+    scrollToTag(m_lastTagName);
+}
+
+void TagExplorer::loadTagChildren(const QSharedPointer<Tag> &p_tag, QTreeWidgetItem *p_parentItem)
+{
+    for (const auto &child : p_tag->getChildren()) {
+        auto item = new QTreeWidgetItem(p_parentItem);
+        fillTagItem(child, item);
+        loadTagChildren(child, item);
+    }
+}
+
+void TagExplorer::fillTagItem(const QSharedPointer<Tag> &p_tag, QTreeWidgetItem *p_item) const
+{
+    p_item->setText(Column::Name, p_tag->name());
+    p_item->setToolTip(Column::Name, p_tag->name());
+    p_item->setIcon(Column::Name, m_tagIcon);
+    p_item->setData(Column::Name, Qt::UserRole, p_tag->name());
+}
+
+void TagExplorer::activateTagItem()
+{
+    auto items = m_tagTree->selectedItems();
+    if (items.size() != 1) {
+        m_lastTagName.clear();
+        m_nodeList->clear();
+        return;
+    }
+
+    m_lastTagName = itemTag(items[0]);
+    updateNodeList(m_lastTagName);
+}
+
+QString TagExplorer::itemTag(const QTreeWidgetItem *p_item) const
+{
+    return p_item->data(Column::Name, Qt::UserRole).toString();
+}
+
+QString TagExplorer::itemNode(const QListWidgetItem *p_item) const
+{
+    return p_item->data(Qt::UserRole).toString();
+}
+
+void TagExplorer::updateNodeList(const QString &p_tag)
+{
+    m_nodeList->clear();
+
+    Q_ASSERT(m_notebook);
+    auto tagI = m_notebook->tag();
+    Q_ASSERT(tagI);
+    const auto nodePaths = tagI->findNodesOfTag(p_tag);
+    for (const auto &pa : nodePaths) {
+        auto node = m_notebook->loadNodeByPath(pa);
+        if (!node) {
+            qWarning() << "node belongs to tag in DB but not exists" << p_tag << pa;
+            continue;
+        }
+
+        if (m_notebook->isNodeInRecycleBin(node.data())) {
+            qDebug() << "skipped node in recycle bin" << p_tag << pa;
+            continue;
+        }
+
+        auto item = new QListWidgetItem(m_nodeList);
+        item->setText(node->getName());
+        item->setToolTip(NotebookNodeExplorer::generateToolTip(node.data()));
+        item->setIcon(m_nodeIcon);
+        item->setData(Qt::UserRole, pa);
+    }
+
+    VNoteX::getInst().showStatusMessageShort(tr("Search of tag succeeded: %1").arg(p_tag));
+}
+
+void TagExplorer::handleNodeListContextMenuRequested(const QPoint &p_pos)
+{
+    if (!m_notebook) {
+        return;
+    }
+
+    auto item = m_nodeList->itemAt(p_pos);
+    if (!item) {
+        return;
+    }
+
+    QMenu menu(this);
+
+    const int selectedCount = m_nodeList->selectedItems().size();
+
+    menu.addAction(tr("&Open"),
+                   &menu,
+                   [this]() {
+                       const auto selectedItems = m_nodeList->selectedItems();
+                       for (const auto &selectedItem : selectedItems) {
+                           openItem(selectedItem);
+                       }
+                   });
+
+    if (selectedCount == 1) {
+        menu.addAction(tr("&Locate Node"),
+                       &menu,
+                       [this]() {
+                           auto item = m_nodeList->currentItem();
+                           if (!item) {
+                               return;
+                           }
+
+                           auto node = m_notebook->loadNodeByPath(itemNode(item));
+                           Q_ASSERT(node);
+                           if (node) {
+                               emit VNoteX::getInst().locateNodeRequested(node.data());
+                           }
+                       });
+    }
+
+    menu.exec(m_nodeList->mapToGlobal(p_pos));
+}
+
+void TagExplorer::openItem(const QListWidgetItem *p_item)
+{
+    if (!p_item) {
+        return;
+    }
+
+    Q_ASSERT(m_notebook);
+    auto node = m_notebook->loadNodeByPath(itemNode(p_item));
+    if (node) {
+        emit VNoteX::getInst().openNodeRequested(node.data(), QSharedPointer<FileOpenParameters>::create());
+    }
+}
+
+void TagExplorer::handleTagTreeContextMenuRequested(const QPoint &p_pos)
+{
+    if (!m_notebook) {
+        return;
+    }
+
+    QMenu menu(this);
+
+    auto item = m_tagTree->itemAt(p_pos);
+
+    menu.addAction(tr("&New Tag"), this, &TagExplorer::newTag);
+
+    if (item && m_tagTree->selectedItems().size() == 1) {
+        menu.addAction(tr("&Rename"), this, &TagExplorer::renameTag);
+
+        menu.addAction(tr("&Delete"), this, &TagExplorer::removeTag);
+    }
+
+    menu.exec(m_tagTree->mapToGlobal(p_pos));
+}
+
+void TagExplorer::newTag()
+{
+    Q_ASSERT(m_notebook);
+
+    QSharedPointer<Tag> parentTag;
+
+    auto item = m_tagTree->currentItem();
+    if (item) {
+        const auto tagName = itemTag(item);
+        parentTag = m_notebook->tag()->findTag(tagName);
+        Q_ASSERT(parentTag);
+    }
+
+    NewTagDialog dialog(m_notebook->tag(), parentTag.data(), VNoteX::getInst().getMainWindow());
+    dialog.exec();
+}
+
+void TagExplorer::renameTag()
+{
+    Q_ASSERT(m_notebook);
+    auto item = m_tagTree->currentItem();
+    if (!item) {
+        return;
+    }
+
+    RenameTagDialog dialog(m_notebook->tag(), itemTag(item), VNoteX::getInst().getMainWindow());
+    if (dialog.exec() == QDialog::Accepted) {
+        scrollToTag(dialog.getTagName());
+    }
+}
+
+void TagExplorer::removeTag()
+{
+    Q_ASSERT(m_notebook);
+    auto item = m_tagTree->currentItem();
+    if (!item) {
+        return;
+    }
+
+    const auto tagName = itemTag(item);
+    int okRet = MessageBoxHelper::questionOkCancel(MessageBoxHelper::Warning,
+        tr("Delete the tag and all its chlidren tags?"),
+        tr("Only tags and the references of them will be deleted."),
+        QString(),
+        VNoteX::getInst().getMainWindow());
+    if (okRet != QMessageBox::Ok) {
+        return;
+    }
+
+    if (m_notebook->tag()->removeTag(tagName)) {
+        VNoteX::getInst().showStatusMessageShort(tr("Tag deleted"));
+    } else {
+        VNoteX::getInst().showStatusMessageShort(tr("Failed to delete tag: %1").arg(tagName));
+    }
+}
+
+void TagExplorer::handleTagMoved(QTreeWidgetItem *p_item)
+{
+    const auto tagName = itemTag(p_item);
+    auto tag = m_notebook->tag()->findTag(tagName);
+    Q_ASSERT(tag);
+    const auto oldParentName = tag->getParent() ? tag->getParent()->name() : QString();
+    const auto newParentName = p_item->parent() ? itemTag(p_item->parent()) : QString();
+    if (oldParentName == newParentName) {
+        // Sorting tags is not supported for now.
+        return;
+    }
+
+    qDebug() << "re-parent tag" << tagName << oldParentName << "->" << newParentName;
+    bool ret = m_notebook->tag()->moveTag(tagName, newParentName);
+    if (!ret) {
+        MessageBoxHelper::notify(MessageBoxHelper::Type::Warning,
+                                tr("Failed to move tag (%1).").arg(tagName),
+                                VNoteX::getInst().getMainWindow());
+    }
+}
+
+void TagExplorer::scrollToTag(const QString &p_name)
+{
+    if (p_name.isEmpty()) {
+        return;
+    }
+
+    auto item = TreeWidget::findItem(m_tagTree, p_name, Column::Name);
+    if (item) {
+        m_tagTree->setCurrentItem(item);
+        m_tagTree->scrollToItem(item);
+    }
+}

+ 105 - 0
src/widgets/tagexplorer.h

@@ -0,0 +1,105 @@
+#ifndef TAGEXPLORER_H
+#define TAGEXPLORER_H
+
+#include <QFrame>
+#include <QSharedPointer>
+#include <QScopedPointer>
+
+#include "navigationmodewrapper.h"
+
+class QListWidget;
+class QListWidgetItem;
+class QTreeWidget;
+class QTreeWidgetItem;
+class QSplitter;
+
+namespace vnotex
+{
+    class TitleBar;
+    class Notebook;
+    class Tag;
+    class TreeWidget;
+
+    class TagExplorer : public QFrame
+    {
+        Q_OBJECT
+    public:
+        explicit TagExplorer(QWidget *p_parent = nullptr);
+
+        QByteArray saveState() const;
+
+        void restoreState(const QByteArray &p_data);
+
+    public slots:
+        void setNotebook(const QSharedPointer<Notebook> &p_notebook);
+
+    private slots:
+        void handleNodeListContextMenuRequested(const QPoint &p_pos);
+
+        void handleTagTreeContextMenuRequested(const QPoint &p_pos);
+
+        void handleTagMoved(QTreeWidgetItem *p_item);
+
+    private:
+        enum Column { Name = 0 };
+
+        void initIcons();
+
+        void setupUI();
+
+        void setupTitleBar(QWidget *p_parent = nullptr);
+
+        void setupTagTree(QWidget *p_parent = nullptr);
+
+        void setupNodeList(QWidget *p_parent = nullptr);
+
+        void setTwoColumnsEnabled(bool p_enabled);
+
+        void updateTags();
+
+        void loadTagChildren(const QSharedPointer<Tag> &p_tag, QTreeWidgetItem *p_parentItem);
+
+        void fillTagItem(const QSharedPointer<Tag> &p_tag, QTreeWidgetItem *p_item) const;
+
+        void activateTagItem();
+
+        QString itemTag(const QTreeWidgetItem *p_item) const;
+
+        QString itemNode(const QListWidgetItem *p_item) const;
+
+        void updateNodeList(const QString &p_tag);
+
+        void openItem(const QListWidgetItem *p_item);
+
+        void newTag();
+
+        void renameTag();
+
+        void removeTag();
+
+        void scrollToTag(const QString &p_name);
+
+        QSharedPointer<Notebook> m_notebook;
+
+        // Used to cache current selected tag after update.
+        QString m_lastTagName;
+
+        TitleBar *m_titleBar = nullptr;
+
+        QSplitter *m_splitter = nullptr;
+
+        TreeWidget *m_tagTree = nullptr;
+
+        QScopedPointer<NavigationModeWrapper<QTreeWidget, QTreeWidgetItem>> m_tagTreeNavigationWrapper;
+
+        QListWidget *m_nodeList = nullptr;
+
+        QScopedPointer<NavigationModeWrapper<QListWidget, QListWidgetItem>> m_nodeListNavigationWrapper;
+
+        QIcon m_tagIcon;
+
+        QIcon m_nodeIcon;
+    };
+}
+
+#endif // TAGEXPLORER_H

+ 49 - 0
src/widgets/tagpopup.cpp

@@ -0,0 +1,49 @@
+#include "tagpopup.h"
+
+#include <QHBoxLayout>
+#include <QVBoxLayout>
+
+#include <utils/widgetutils.h>
+#include <buffer/buffer.h>
+
+#include "tagviewer.h"
+
+using namespace vnotex;
+
+TagPopup::TagPopup(QToolButton *p_btn, QWidget *p_parent)
+    : QMenu(p_parent),
+      m_button(p_btn)
+{
+    setupUI();
+
+    connect(this, &QMenu::aboutToShow,
+            this, [this]() {
+                m_tagViewer->setNode(m_buffer ? m_buffer->getNode() : nullptr);
+                // Enable input method.
+                m_tagViewer->activateWindow();
+                m_tagViewer->setFocus();
+            });
+
+    connect(this, &QMenu::aboutToHide,
+            m_tagViewer, &TagViewer::save);
+}
+
+void TagPopup::setupUI()
+{
+    auto mainLayout = new QVBoxLayout(this);
+    WidgetUtils::setContentsMargins(mainLayout);
+
+    m_tagViewer = new TagViewer(this);
+    mainLayout->addWidget(m_tagViewer);
+
+    setMinimumSize(256, 320);
+}
+
+void TagPopup::setBuffer(Buffer *p_buffer)
+{
+    if (m_buffer == p_buffer) {
+        return;
+    }
+
+    m_buffer = p_buffer;
+}

+ 34 - 0
src/widgets/tagpopup.h

@@ -0,0 +1,34 @@
+#ifndef TAGPOPUP_H
+#define TAGPOPUP_H
+
+#include <QMenu>
+
+class QToolButton;
+
+namespace vnotex
+{
+    class Buffer;
+    class TagViewer;
+
+    class TagPopup : public QMenu
+    {
+        Q_OBJECT
+    public:
+        TagPopup(QToolButton *p_btn, QWidget *p_parent = nullptr);
+
+        void setBuffer(Buffer *p_buffer);
+
+    private:
+        void setupUI();
+
+        Buffer *m_buffer = nullptr;
+
+        // Button for this menu.
+        QToolButton *m_button = nullptr;
+
+        // Managed by QObject.
+        TagViewer *m_tagViewer = nullptr;
+    };
+}
+
+#endif // TAGPOPUP_H

+ 323 - 0
src/widgets/tagviewer.cpp

@@ -0,0 +1,323 @@
+#include "tagviewer.h"
+
+#include <QVBoxLayout>
+#include <QLabel>
+#include <QListWidgetItem>
+#include <QKeyEvent>
+#include <QRegularExpression>
+#include <QGuiApplication>
+#include <QRegularExpressionValidator>
+#include <QRegularExpression>
+#include <QHash>
+
+#include <utils/widgetutils.h>
+#include <utils/iconutils.h>
+#include <notebook/tagi.h>
+#include <notebook/node.h>
+#include <notebook/notebook.h>
+#include <core/vnotex.h>
+
+#include "lineedit.h"
+#include "listwidget.h"
+#include "widgetsfactory.h"
+#include "styleditemdelegate.h"
+#include "messageboxhelper.h"
+#include "mainwindow.h"
+
+using namespace vnotex;
+
+QIcon TagViewer::s_tagIcon;
+
+QIcon TagViewer::s_selectedTagIcon;
+
+TagViewer::TagViewer(QWidget *p_parent)
+    : QFrame(p_parent)
+{
+    initIcons();
+
+    setupUI();
+}
+
+void TagViewer::setupUI()
+{
+    auto mainLayout = new QVBoxLayout(this);
+
+    m_searchLineEdit = static_cast<LineEdit *>(WidgetsFactory::createLineEdit(this));
+    m_searchLineEdit->setPlaceholderText(tr("Enter to add a tag"));
+    m_searchLineEdit->setToolTip(tr("[Shift+Enter] to add current selected tag in the list"));
+    connect(m_searchLineEdit, &QLineEdit::textChanged,
+            this, &TagViewer::searchAndFilter);
+    connect(m_searchLineEdit, &QLineEdit::returnPressed,
+            this, &TagViewer::handleSearchLineEditReturnPressed);
+    mainLayout->addWidget(m_searchLineEdit);
+
+    auto tagNameValidator = new QRegularExpressionValidator(QRegularExpression("[^>]*"), m_searchLineEdit);
+    m_searchLineEdit->setValidator(tagNameValidator);
+
+    setFocusProxy(m_searchLineEdit);
+    m_searchLineEdit->installEventFilter(this);
+
+    m_tagList = new ListWidget(this);
+    m_tagList->setWrapping(true);
+    m_tagList->setFlow(QListView::LeftToRight);
+    m_tagList->setIconSize(QSize(18, 18));
+    connect(m_tagList, &QListWidget::itemClicked,
+            this, &TagViewer::toggleItemTag);
+    connect(m_tagList, &QListWidget::itemActivated,
+            this, &TagViewer::toggleItemTag);
+    mainLayout->addWidget(m_tagList);
+
+    m_tagList->installEventFilter(this);
+}
+
+bool TagViewer::eventFilter(QObject *p_obj, QEvent *p_event)
+{
+    if ((p_obj == m_searchLineEdit || p_obj == m_tagList)
+        && p_event->type() == QEvent::KeyPress) {
+        auto keyEve = static_cast<QKeyEvent *>(p_event);
+        const auto key = keyEve->key();
+        if (key == Qt::Key_Tab || key == Qt::Key_Backtab) {
+            // Change focus.
+            if (p_obj == m_searchLineEdit) {
+                m_tagList->setFocus();
+            } else {
+                m_searchLineEdit->setFocus();
+            }
+            return true;
+        }
+    }
+    return QFrame::eventFilter(p_obj, p_event);
+}
+
+void TagViewer::setNode(Node *p_node)
+{
+    // Since there may be update on tags, always update the list.
+    // When first time viewing the tags of one node, it is a good chance to sync the node's tag to DB.
+    if (m_node != p_node) {
+        m_node = p_node;
+        if (m_node) {
+            bool ret = tagI()->updateNodeTags(m_node);
+            if (!ret) {
+                qWarning() << "failed to update tags of node" << m_node->fetchPath();
+            }
+        }
+    }
+
+    m_hasChange = false;
+
+    updateTagList();
+}
+
+void TagViewer::updateTagList()
+{
+    m_tagList->clear();
+    if (!m_node) {
+        return;
+    }
+
+    QSet<QString> tagsAdded;
+    const auto &nodeTags = m_node->getTags();
+    for (const auto &tag : nodeTags) {
+        if (tagsAdded.contains(tag)) {
+            continue;
+        }
+
+        tagsAdded.insert(tag);
+        addTagItem(tag, true);
+    }
+
+    const auto &allTags = tagI()->getTopLevelTags();
+    for (const auto &tag : allTags) {
+        addTags(tag, tagsAdded);
+    }
+
+    if (!tagsAdded.isEmpty()) {
+        m_tagList->setCurrentRow(0);
+        // Qt's BUG: need to set it again to make it in grid form after setCurrentRow().
+        m_tagList->setWrapping(true);
+    }
+}
+
+void TagViewer::addTags(const QSharedPointer<Tag> &p_tag, QSet<QString> &p_addedTags)
+{
+    // Itself.
+    if (!p_addedTags.contains(p_tag->name())) {
+        p_addedTags.insert(p_tag->name());
+        addTagItem(p_tag->name(), false);
+    }
+
+    // Children.
+    for (const auto &child : p_tag->getChildren()) {
+        addTags(child, p_addedTags);
+    }
+}
+
+void TagViewer::initIcons()
+{
+    if (!s_tagIcon.isNull()) {
+        return;
+    }
+
+    const auto &themeMgr = VNoteX::getInst().getThemeMgr();
+    s_tagIcon = IconUtils::fetchIcon(themeMgr.getIconFile(QStringLiteral("tag.svg")));
+    s_selectedTagIcon = IconUtils::fetchIcon(themeMgr.getIconFile(QStringLiteral("tag_selected.svg")));
+}
+
+void TagViewer::addTagItem(const QString &p_tagName, bool p_selected, bool p_prepend)
+{
+    auto item = new QListWidgetItem(p_tagName);
+    if (!p_prepend) {
+        m_tagList->addItem(item);
+    } else {
+        m_tagList->insertItem(0, item);
+    }
+
+    item->setToolTip(p_tagName);
+    item->setData(Qt::UserRole, p_tagName);
+    setItemTagSelected(item, p_selected);
+}
+
+QString TagViewer::itemTag(const QListWidgetItem *p_item) const
+{
+    return p_item->data(Qt::UserRole).toString();
+}
+
+bool TagViewer::isItemTagSelected(const QListWidgetItem *p_item) const
+{
+    return p_item->data(UserRole2).toBool();
+}
+
+TagI *TagViewer::tagI()
+{
+    return m_node->getNotebook()->tag();
+}
+
+void TagViewer::searchAndFilter(const QString &p_text)
+{
+    // Take the last tag for search.
+    const auto text = p_text.trimmed();
+
+    if (text.isEmpty()) {
+        // Show all items.
+        filterItems([](const QListWidgetItem *) {
+                return true;
+            });
+        return;
+    }
+
+    filterItems([this, &text](const QListWidgetItem *p_item) {
+            if (itemTag(p_item).contains(text)) {
+                return true;
+            }
+            return false;
+        });
+}
+
+void TagViewer::filterItems(const std::function<bool(const QListWidgetItem *)> &p_judge)
+{
+    QListWidgetItem *firstHit = nullptr;
+    ListWidget::forEachItem(m_tagList, [&firstHit, &p_judge](QListWidgetItem *itemIter) {
+            if (p_judge(itemIter)) {
+                if (!firstHit) {
+                    firstHit = itemIter;
+                }
+                itemIter->setHidden(false);
+            } else {
+                itemIter->setHidden(true);
+            }
+            return true;
+        });
+    m_tagList->setCurrentItem(firstHit);
+}
+
+void TagViewer::handleSearchLineEditReturnPressed()
+{
+    if (QGuiApplication::keyboardModifiers() == Qt::ShiftModifier) {
+        // Add current selected tag in the list.
+        auto item = m_tagList->currentItem();
+        if (item && !isItemTagSelected(item)) {
+            setItemTagSelected(item, true);
+            m_searchLineEdit->clear();
+            m_hasChange = true;
+        }
+    } else {
+        // Decode input text and add tags.
+        const auto tagName = m_searchLineEdit->text().trimmed();
+        if (tagName.isEmpty()) {
+            return;
+        }
+
+        if (auto item = findItem(tagName)) {
+            // Add existing tag.
+            setItemTagSelected(item, true);
+        } else {
+            // Add new tag.
+            addTagItem(tagName, true, true);
+        }
+
+        m_searchLineEdit->clear();
+        m_hasChange = true;
+    }
+}
+
+void TagViewer::toggleItemTag(QListWidgetItem *p_item)
+{
+    m_hasChange = true;
+    setItemTagSelected(p_item, !isItemTagSelected(p_item));
+}
+
+void TagViewer::setItemTagSelected(QListWidgetItem *p_item, bool p_selected)
+{
+    p_item->setIcon(p_selected ? s_selectedTagIcon : s_tagIcon);
+    p_item->setData(UserRole2, p_selected);
+}
+
+QListWidgetItem *TagViewer::findItem(const QString &p_tagName) const
+{
+    return ListWidget::findItem(m_tagList, p_tagName);
+}
+
+void TagViewer::save()
+{
+    if (!m_node || !m_hasChange) {
+        return;
+    }
+
+    QHash<QString, int> selectedTags;
+    ListWidget::forEachItem(m_tagList, [this, &selectedTags](QListWidgetItem *itemIter) {
+            if (isItemTagSelected(itemIter)) {
+                selectedTags.insert(itemTag(itemIter), 0);
+            }
+            return true;
+        });
+
+    if (selectedTags.size() == m_node->getTags().size()) {
+        bool same = true;
+        for (const auto &tag : m_node->getTags()) {
+            auto iter = selectedTags.find(tag);
+            if (iter == selectedTags.end()) {
+                same = false;
+                break;
+            } else {
+                iter.value()++;
+                if (iter.value() > 1) {
+                    same = false;
+                    break;
+                }
+            }
+        }
+
+        if (same) {
+            return;
+        }
+    }
+
+    bool ret = tagI()->updateNodeTags(m_node, selectedTags.keys());
+    if (ret) {
+        VNoteX::getInst().showStatusMessageShort(tr("Tags updated: %1").arg(m_node->getTags().join(QLatin1String("; "))));
+    } else {
+        MessageBoxHelper::notify(MessageBoxHelper::Type::Warning,
+                                 tr("Failed to update tags of node (%1).").arg(m_node->getName()),
+                                 VNoteX::getInst().getMainWindow());
+    }
+}

+ 79 - 0
src/widgets/tagviewer.h

@@ -0,0 +1,79 @@
+#ifndef TAGVIEWER_H
+#define TAGVIEWER_H
+
+#include <QFrame>
+
+#include <functional>
+
+#include <QIcon>
+#include <QSharedPointer>
+#include <QSet>
+
+class QListWidget;
+class QListWidgetItem;
+
+namespace vnotex
+{
+    class LineEdit;
+    class Node;
+    class TagI;
+    class Tag;
+
+    class TagViewer : public QFrame
+    {
+        Q_OBJECT
+    public:
+        explicit TagViewer(QWidget *p_parent = nullptr);
+
+        void setNode(Node *p_node);
+
+        void save();
+
+    protected:
+        bool eventFilter(QObject *p_obj, QEvent *p_event) Q_DECL_OVERRIDE;
+
+    private:
+        void setupUI();
+
+        void updateTagList();
+
+        TagI *tagI();
+
+        void addTagItem(const QString &p_tagName, bool p_selected, bool p_prepend = false);
+
+        QString itemTag(const QListWidgetItem *p_item) const;
+
+        bool isItemTagSelected(const QListWidgetItem *p_item) const;
+
+        void addTags(const QSharedPointer<Tag> &p_tag, QSet<QString> &p_addedTags);
+
+        void searchAndFilter(const QString &p_text);
+
+        void filterItems(const std::function<bool(const QListWidgetItem *)> &p_judge);
+
+        void handleSearchLineEditReturnPressed();
+
+        void toggleItemTag(QListWidgetItem *p_item);
+
+        void setItemTagSelected(QListWidgetItem *p_item, bool p_selected);
+
+        QListWidgetItem *findItem(const QString &p_tagName) const;
+
+        static void initIcons();
+
+        // View the tags of @m_node.
+        Node *m_node = nullptr;
+
+        bool m_hasChange = false;
+
+        LineEdit *m_searchLineEdit = nullptr;
+
+        QListWidget *m_tagList = nullptr;
+
+        static QIcon s_tagIcon;
+
+        static QIcon s_selectedTagIcon;
+    };
+}
+
+#endif // TAGVIEWER_H

+ 2 - 0
src/widgets/textviewwindow.cpp

@@ -68,6 +68,8 @@ void TextViewWindow::setupToolBar()
 
     addAction(toolBar, ViewWindowToolBarHelper::Attachment);
 
+    addAction(toolBar, ViewWindowToolBarHelper::Tag);
+
     ToolBarHelper::addSpacer(toolBar);
     addAction(toolBar, ViewWindowToolBarHelper::FindAndReplace);
 }

+ 1 - 1
src/widgets/toolbarhelper.cpp

@@ -352,7 +352,7 @@ QToolBar *ToolBarHelper::setupSettingsToolBar(MainWindow *p_win, QToolBar *p_too
             menu->addAction(fullScreenAct);
         }
 
-        auto stayOnTopAct = menu->addAction(generateIcon("stay_on_top.svg"), MainWindow::tr("Stay On Top"),
+        auto stayOnTopAct = menu->addAction(generateIcon("stay_on_top.svg"), MainWindow::tr("Stay on Top"),
                                             p_win, &MainWindow::setStayOnTop);
         stayOnTopAct->setCheckable(true);
         WidgetUtils::addActionShortcut(stayOnTopAct,

+ 7 - 22
src/widgets/treewidget.cpp

@@ -58,11 +58,11 @@ void TreeWidget::showHorizontalScrollbar(QTreeWidget *p_tree)
     p_tree->header()->setStretchLastSection(false);
 }
 
-QTreeWidgetItem *TreeWidget::findItem(const QTreeWidget *p_widget, const QVariant &p_data)
+QTreeWidgetItem *TreeWidget::findItem(const QTreeWidget *p_widget, const QVariant &p_data, int p_column)
 {
     int nrTop = p_widget->topLevelItemCount();
     for (int i = 0; i < nrTop; ++i) {
-        auto item = findItemHelper(p_widget->topLevelItem(i), p_data);
+        auto item = findItemHelper(p_widget->topLevelItem(i), p_data, p_column);
         if (item) {
             return item;
         }
@@ -71,7 +71,7 @@ QTreeWidgetItem *TreeWidget::findItem(const QTreeWidget *p_widget, const QVarian
     return nullptr;
 }
 
-QTreeWidgetItem *TreeWidget::findItemHelper(QTreeWidgetItem *p_item, const QVariant &p_data)
+QTreeWidgetItem *TreeWidget::findItemHelper(QTreeWidgetItem *p_item, const QVariant &p_data, int p_column)
 {
     if (!p_item) {
         return nullptr;
@@ -83,7 +83,7 @@ QTreeWidgetItem *TreeWidget::findItemHelper(QTreeWidgetItem *p_item, const QVari
 
     int nrChild = p_item->childCount();
     for (int i = 0; i < nrChild; ++i) {
-        auto item = findItemHelper(p_item->child(i), p_data);
+        auto item = findItemHelper(p_item->child(i), p_data, p_column);
         if (item) {
             return item;
         }
@@ -217,24 +217,9 @@ void TreeWidget::dropEvent(QDropEvent *p_event)
 {
     auto dragItems = selectedItems();
 
-    int first = -1, last = -1;
-    QTreeWidgetItem *firstItem = NULL;
-    for (int i = 0; i < dragItems.size(); ++i) {
-        int row = indexFromItem(dragItems[i]).row();
-        if (row > last) {
-            last = row;
-        }
-
-        if (first == -1 || row < first) {
-            first = row;
-            firstItem = dragItems[i];
-        }
-    }
-
-    Q_ASSERT(firstItem);
-
     QTreeWidget::dropEvent(p_event);
 
-    int target = indexFromItem(firstItem).row();
-    emit rowsMoved(first, last, target);
+    if (dragItems.size() == 1) {
+        emit itemMoved(dragItems[0]);
+    }
 }

+ 4 - 4
src/widgets/treewidget.h

@@ -26,7 +26,7 @@ namespace vnotex
 
         static void showHorizontalScrollbar(QTreeWidget *p_tree);
 
-        static QTreeWidgetItem *findItem(const QTreeWidget *p_widget, const QVariant &p_data);
+        static QTreeWidgetItem *findItem(const QTreeWidget *p_widget, const QVariant &p_data, int p_column = 0);
 
         // Next visible item.
         static QTreeWidgetItem *nextItem(const QTreeWidget* p_tree,
@@ -36,8 +36,8 @@ namespace vnotex
         static QVector<QTreeWidgetItem *> getVisibleItems(const QTreeWidget *p_widget);
 
     signals:
-        // Rows [@p_first, @p_last] were moved to @p_row.
-        void rowsMoved(int p_first, int p_last, int p_row);
+        // Emit when single item is selected and Drag&Drop to move internally.
+        void itemMoved(QTreeWidgetItem *p_item);
 
     protected:
         void mousePressEvent(QMouseEvent *p_event) Q_DECL_OVERRIDE;
@@ -47,7 +47,7 @@ namespace vnotex
         void dropEvent(QDropEvent *p_event) Q_DECL_OVERRIDE;
 
     private:
-        static QTreeWidgetItem *findItemHelper(QTreeWidgetItem *p_item, const QVariant &p_data);
+        static QTreeWidgetItem *findItemHelper(QTreeWidgetItem *p_item, const QVariant &p_data, int p_column);
 
         static QTreeWidgetItem *nextSibling(const QTreeWidget *p_widget,
                                             QTreeWidgetItem *p_item,

+ 3 - 1
src/widgets/viewarea.cpp

@@ -1292,7 +1292,9 @@ void ViewArea::takeSnapshot(ViewAreaSession &p_session) const
         }
         wsSnap.m_currentViewWindowIndex = ws->m_currentViewWindowIndex;
         for (auto win : ws->m_viewWindows) {
-            wsSnap.m_viewWindows.push_back(win->saveSession());
+            if (win->isSessionEnabled()) {
+                wsSnap.m_viewWindows.push_back(win->saveSession());
+            }
         }
     }
 }

+ 22 - 0
src/widgets/viewwindow.cpp

@@ -29,6 +29,7 @@
 #include "editreaddiscardaction.h"
 #include "viewsplit.h"
 #include "attachmentpopup.h"
+#include "tagpopup.h"
 #include "outlinepopup.h"
 #include "dragdropareaindicator.h"
 #include "attachmentdragdropareaindicator.h"
@@ -141,6 +142,8 @@ void ViewWindow::handleBufferChanged(const QSharedPointer<FileOpenParameters> &p
                 this, &ViewWindow::attachmentChanged);
     }
 
+    m_sessionEnabled = p_paras->m_sessionEnabled;
+
     handleBufferChangedInternal(p_paras);
 
     emit bufferChanged();
@@ -399,6 +402,19 @@ QAction *ViewWindow::addAction(QToolBar *p_toolBar, ViewWindowToolBarHelper::Act
         break;
     }
 
+    case ViewWindowToolBarHelper::Tag:
+    {
+        act = ViewWindowToolBarHelper::addAction(p_toolBar, p_action);
+        auto popup = static_cast<TagPopup *>(static_cast<QToolButton *>(p_toolBar->widgetForAction(act))->menu());
+        connect(this, &ViewWindow::bufferChanged,
+                this, [this, act, popup]() {
+                    auto buffer = getBuffer();
+                    act->setEnabled(buffer ? buffer->isTagSupported() : false);
+                    popup->setBuffer(buffer);
+                });
+        break;
+    }
+
     case ViewWindowToolBarHelper::Outline:
     {
         act = ViewWindowToolBarHelper::addAction(p_toolBar, p_action);
@@ -1152,6 +1168,7 @@ QToolBar *ViewWindow::createToolBar(QWidget *p_parent)
     toolBar->setIconSize(QSize(iconSize, iconSize));
 
     /*
+    // The extension button of tool bar.
     auto extBtn = toolBar->findChild<QToolButton *>(QLatin1String("qt_toolbar_ext_button"));
     Q_ASSERT(extBtn);
     */
@@ -1239,3 +1256,8 @@ void ViewWindow::updateLastFindInfo(const QStringList &p_texts, FindOptions p_op
     m_findInfo.m_texts = p_texts;
     m_findInfo.m_options = p_options;
 }
+
+bool ViewWindow::isSessionEnabled() const
+{
+    return m_sessionEnabled;
+}

+ 10 - 6
src/widgets/viewwindow.h

@@ -95,6 +95,8 @@ namespace vnotex
         // Return the result from the FloatingWidget.
         QVariant showFloatingWidget(FloatingWidget *p_widget);
 
+        bool isSessionEnabled() const;
+
     public slots:
         virtual void handleEditorConfigChange() = 0;
 
@@ -316,6 +318,14 @@ namespace vnotex
 
         Buffer *m_buffer = nullptr;
 
+        // Whether check file missing or changed outside.
+        bool m_fileChangeCheckEnabled = true;
+
+        // Last find info.
+        FindInfo m_findInfo;
+
+        bool m_sessionEnabled = true;
+
         // Null if this window has not been added to any split.
         ViewSplit *m_viewSplit = nullptr;
 
@@ -342,12 +352,6 @@ namespace vnotex
         // Managed by QObject.
         QToolBar *m_toolBar = nullptr;
 
-        // Whether check file missing or changed outside.
-        bool m_fileChangeCheckEnabled = true;
-
-        // Last find info.
-        FindInfo m_findInfo;
-
         QSharedPointer<StatusWidget> m_statusWidget;
 
         EditReadDiscardAction *m_editReadDiscardAct = nullptr;

+ 18 - 0
src/widgets/viewwindowtoolbarhelper.cpp

@@ -18,6 +18,7 @@
 #include "editreaddiscardaction.h"
 #include "widgetsfactory.h"
 #include "attachmentpopup.h"
+#include "tagpopup.h"
 #include "propertydefs.h"
 #include "outlinepopup.h"
 #include "viewwindow.h"
@@ -293,6 +294,23 @@ QAction *ViewWindowToolBarHelper::addAction(QToolBar *p_tb, Action p_action)
         break;
     }
 
+    case Action::Tag:
+    {
+        act = p_tb->addAction(ToolBarHelper::generateIcon("tag_editor.svg"),
+                              ViewWindow::tr("Tags"));
+
+        auto toolBtn = dynamic_cast<QToolButton *>(p_tb->widgetForAction(act));
+        Q_ASSERT(toolBtn);
+        toolBtn->setPopupMode(QToolButton::InstantPopup);
+        toolBtn->setProperty(PropertyDefs::c_toolButtonWithoutMenuIndicator, true);
+
+        addButtonShortcut(toolBtn, editorConfig.getShortcut(Shortcut::Tag), viewWindow);
+
+        auto menu = new TagPopup(toolBtn, p_tb);
+        toolBtn->setMenu(menu);
+        break;
+    }
+
     case Action::Outline:
     {
         act = p_tb->addAction(ToolBarHelper::generateIcon("outline_editor.svg"),

+ 1 - 0
src/widgets/viewwindowtoolbarhelper.h

@@ -41,6 +41,7 @@ namespace vnotex
             TypeMax,
 
             Attachment,
+            Tag,
             Outline,
             FindAndReplace,
             SectionNumber,

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.