From f4b891efa7ab8716abd0ac7caa4d62c049fb6cfb Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 19:14:03 +0200 Subject: [PATCH] M5: project = directory + Go-to-File switcher (Alt+P) ProjectIndex (project_lib): findRoot walks up for a VCS marker and falls back to the start dir; listFiles prefers git ls-files (--cached --others --exclude-standard) and falls back to a bounded recursive walk skipping noise dirs/symlinks. 12 unit tests. Plugin: Alt+P 'Go to File (project)' opens a second PaletteWidget of the project files, scoped to the active document's root (fallback cwd), and opens the pick via MainWindow::openUrl+activateView. Also surfaced in the M-x palette and reachable from a radial by objectName ollie_goto_file. --- docs/PLAN.md | 20 +++- src/CMakeLists.txt | 1 + src/plugin/CMakeLists.txt | 1 + src/plugin/ollieplugin.cpp | 113 +++++++++++++++++++ src/plugin/ollieplugin.h | 4 + src/project/CMakeLists.txt | 14 +++ src/project/projectindex.cpp | 173 ++++++++++++++++++++++++++++++ src/project/projectindex.h | 80 ++++++++++++++ src/project/test_projectindex.cpp | 153 ++++++++++++++++++++++++++ 9 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 src/project/CMakeLists.txt create mode 100644 src/project/projectindex.cpp create mode 100644 src/project/projectindex.h create mode 100644 src/project/test_projectindex.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index 47e60cb..0de9937 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -186,8 +186,24 @@ Therefore the palette is replaced, not extended, and the replacement is built on constructor is NOT visible to `Editor::queryCommand()`/`commandList()` on this build, so the verbs are not (yet) reachable from Kate's `:` command line — only via the palette. Proper command-line registration is a follow-up. -- Project = directory ("open folder == open project"): TODO. -- Fuzzy file / symbol switchers scoped to the folder: TODO. +- Project = directory ("open folder == open project"): DONE (core + switcher). + `src/project/projectindex.{h,cpp}` (`project_lib`, Qt::Core only, 12 unit + tests): + - `findRoot(startDir)` walks up for a VCS marker (`.git`/`.hg`/`.svn`) and + returns that ancestor, else the start directory itself — so any folder is a + usable project with zero config. + - `listFiles(root)` prefers `git ls-files --cached --others + --exclude-standard` inside a git work tree (honours .gitignore, includes + untracked-not-ignored), and falls back to a bounded recursive walk + (`walkDirectory`) that skips noise dirs (node_modules, build, target, …) and + symlinks when there is no git (or git yields nothing). + - Wired into the plugin: **Alt+P** "Go to File (project)" opens a second + PaletteWidget populated with the project's files (base name as the label, + directory as the trailing hint). The root is derived from the active + document's directory (fallback: cwd). Activation opens the file via + `MainWindow::openUrl` + `activateView`. Also reachable from the M-x palette + (`ollie:switch:file`) and, by objectName `ollie_goto_file`, from a radial. +- Fuzzy symbol switcher scoped to the folder: TODO (ctags/LSP; ctags present). ## Build and test diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f4f2215..b0c7bd0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,7 @@ add_subdirectory(fuzzy) add_subdirectory(palette) add_subdirectory(radial) +add_subdirectory(project) if(KF6TextEditor_FOUND) add_subdirectory(commands) endif() diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt index 9637d53..cc9035f 100644 --- a/src/plugin/CMakeLists.txt +++ b/src/plugin/CMakeLists.txt @@ -19,6 +19,7 @@ target_link_libraries(olliepalette PRIVATE palette radial olliecommands_lib + project_lib KF6::TextEditor KF6::XmlGui KF6::CoreAddons diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index bba3943..50ad3c7 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -7,6 +7,7 @@ #include "radialmenu.h" #include "radialconfig.h" #include "olliecommands.h" +#include "projectindex.h" #include #include @@ -22,12 +23,15 @@ #include #include +#include #include +#include #include #include #include #include #include +#include #include namespace katecustom @@ -128,6 +132,10 @@ KTextEditor::Cursor findOpener(KTextEditor::Document *doc, KTextEditor::Cursor p // than a QAction to trigger. const QLatin1String kCmdPrefix("ollie:cmd:"); +// Prefix marking a palette id that is a project-relative file path to open in +// the file switcher. +const QLatin1String kFilePrefix("ollie:file:"); + // The complete (argument-free) command verbs offered in the palette. Verbs that // need free-text arguments (pipe) stay on the command line; `case` is expanded // into one entry per style so each is directly runnable. @@ -153,6 +161,26 @@ QList paletteVerbs() {QStringLiteral("uuid"), QStringLiteral("Insert UUID")}, }; } + +// Resolve the project root for \a mainWindow: the discovered root of the active +// document's directory, falling back to the process working directory when no +// document is open or the document is untitled. +QString projectRootFor(KTextEditor::MainWindow *mainWindow) +{ + QString startDir; + if (KTextEditor::View *view = mainWindow->activeView()) { + if (KTextEditor::Document *doc = view->document()) { + const QUrl url = doc->url(); + if (url.isLocalFile()) { + startDir = QFileInfo(url.toLocalFile()).absolutePath(); + } + } + } + if (startDir.isEmpty()) { + startDir = QDir::currentPath(); + } + return ProjectIndex::findRoot(startDir); +} } // namespace // --------------------------------------------------------------------------- @@ -195,6 +223,19 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) w->addAction(openAction); } + // Project "Go to File" switcher: Alt+P (single modifier; the Sublime/VSCode + // Ctrl+P idiom adapted to one-handed use). Lists the project's files — + // "open folder == open project" — scoped to the active document's git/root + // directory. + auto *gotoFileAction = new QAction(i18n("Go to File (project)"), this); + gotoFileAction->setObjectName(QStringLiteral("ollie_goto_file")); + gotoFileAction->setShortcut(QKeySequence(Qt::ALT | Qt::Key_P)); + gotoFileAction->setShortcutContext(Qt::ApplicationShortcut); + connect(gotoFileAction, &QAction::triggered, this, &OllieView::showFileSwitcher); + if (QWidget *w = m_mainWindow->window()) { + w->addAction(gotoFileAction); + } + setupRadials(); // One application-wide filter serves both the mouse-button radial gesture @@ -206,6 +247,7 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) OllieView::~OllieView() { delete m_palette; + delete m_filePalette; delete m_radial; } @@ -360,6 +402,15 @@ void OllieView::showCommandPalette() items.push_back(item); } + // Offer the project file switcher as a palette entry too (also on Alt+P). + { + PaletteItem item; + item.id = QStringLiteral("ollie:switch:file"); + item.label = i18n("Go to File (project)"); + item.group = QStringLiteral("Alt+P"); + items.push_back(item); + } + m_palette->setItems(items); // Center the palette near the top of the active window. @@ -387,12 +438,74 @@ void OllieView::runAction(const QString &actionId) return; } + // The project file switcher, surfaced as a palette entry. + if (actionId == QLatin1String("ollie:switch:file")) { + showFileSwitcher(); + return; + } + QPointer action = m_actionsById.value(actionId); if (action) { action->trigger(); } } +void OllieView::showFileSwitcher() +{ + if (!m_filePalette) { + m_filePalette = new PaletteWidget(m_mainWindow->window()); + connect(m_filePalette, &PaletteWidget::activatedId, this, + [this](const QString &id) { + if (id.startsWith(kFilePrefix)) { + openProjectFile(id.mid(QString(kFilePrefix).size())); + } + }); + } + + m_fileSwitcherRoot = projectRootFor(m_mainWindow); + const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot); + + QList items; + items.reserve(files.size()); + for (const QString &rel : files) { + PaletteItem item; + item.id = kFilePrefix + rel; + // Show the file name as the label and the directory as the trailing + // hint, so fuzzy matching favours the base name while the path stays + // visible for disambiguation. + const int slash = rel.lastIndexOf(QLatin1Char('/')); + if (slash >= 0) { + item.label = rel.mid(slash + 1); + item.group = rel.left(slash); + } else { + item.label = rel; + } + items.push_back(item); + } + m_filePalette->setItems(items); + + if (QWidget *w = m_mainWindow->window()) { + const QSize sz = m_filePalette->size(); + const QPoint topLeft = w->mapToGlobal(QPoint((w->width() - sz.width()) / 2, + w->height() / 6)); + m_filePalette->move(topLeft); + } + + m_filePalette->activate(); +} + +void OllieView::openProjectFile(const QString &relativePath) +{ + if (relativePath.isEmpty() || m_fileSwitcherRoot.isEmpty()) { + return; + } + const QString absolute = QDir(m_fileSwitcherRoot).absoluteFilePath(relativePath); + const QUrl url = QUrl::fromLocalFile(absolute); + if (KTextEditor::View *view = m_mainWindow->openUrl(url)) { + m_mainWindow->activateView(view->document()); + } +} + QAction *OllieView::findActionByName(const QString &nameOrText) const { KXMLGUIFactory *factory = m_mainWindow->guiFactory(); diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 5632d27..6ee5326 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -60,6 +60,8 @@ public: private Q_SLOTS: void showCommandPalette(); void runAction(const QString &actionId); + void showFileSwitcher(); + void openProjectFile(const QString &relativePath); private: // One harvested, runnable action with the provenance needed to build a @@ -105,6 +107,8 @@ private: KTextEditor::MainWindow *m_mainWindow; PaletteWidget *m_palette = nullptr; + PaletteWidget *m_filePalette = nullptr; // project "Go to File" switcher + QString m_fileSwitcherRoot; // project root for the open switcher RadialMenu *m_radial = nullptr; QList m_radialDefs; QHash m_buttonToRadial; // Qt::MouseButton (int) -> def index diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt new file mode 100644 index 0000000..5b81b1a --- /dev/null +++ b/src/project/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(project_lib STATIC + projectindex.cpp + projectindex.h +) +target_link_libraries(project_lib PUBLIC + Qt6::Core +) +target_include_directories(project_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +if(Qt6Test_FOUND) + add_executable(test_projectindex test_projectindex.cpp) + target_link_libraries(test_projectindex PRIVATE project_lib Qt6::Core Qt6::Test) + add_test(NAME projectindex COMMAND test_projectindex) +endif() diff --git a/src/project/projectindex.cpp b/src/project/projectindex.cpp new file mode 100644 index 0000000..e74e810 --- /dev/null +++ b/src/project/projectindex.cpp @@ -0,0 +1,173 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "projectindex.h" + +#include +#include +#include +#include + +namespace katecustom +{ + +QStringList ProjectIndex::rootMarkers() +{ + return { + QStringLiteral(".git"), + QStringLiteral(".hg"), + QStringLiteral(".svn"), + }; +} + +QStringList ProjectIndex::ignoredDirs() +{ + return { + QStringLiteral(".git"), + QStringLiteral(".hg"), + QStringLiteral(".svn"), + QStringLiteral("node_modules"), + QStringLiteral("build"), + QStringLiteral(".build"), + QStringLiteral("target"), + QStringLiteral("dist"), + QStringLiteral(".cache"), + QStringLiteral("__pycache__"), + QStringLiteral(".venv"), + QStringLiteral("venv"), + }; +} + +QString ProjectIndex::findRoot(const QString &startDir) +{ + if (startDir.isEmpty()) { + return QString(); + } + QDir dir(startDir); + // Accept a file path too: step to its containing directory. + if (QFileInfo(startDir).isFile()) { + dir = QFileInfo(startDir).dir(); + } + if (!dir.exists()) { + return QString(); + } + + const QStringList markers = rootMarkers(); + QDir probe = dir; + while (true) { + for (const QString &marker : markers) { + if (probe.exists(marker)) { + return probe.absolutePath(); + } + } + if (!probe.cdUp()) { + break; // reached the filesystem root with no marker + } + } + return dir.absolutePath(); +} + +bool ProjectIndex::isGitRoot(const QString &dir) +{ + if (dir.isEmpty()) { + return false; + } + return QFileInfo::exists(QDir(dir).filePath(QStringLiteral(".git"))); +} + +QStringList ProjectIndex::gitListFiles(const QString &root) +{ + QProcess git; + git.setWorkingDirectory(root); + // Tracked files, plus untracked files that are not ignored. --exclude-standard + // applies .gitignore / .git/info/exclude / global excludes to the untracked + // set. -z would need manual splitting; newline output is sufficient here + // (paths with newlines are vanishingly rare and git quotes them otherwise). + git.start(QStringLiteral("git"), + {QStringLiteral("ls-files"), + QStringLiteral("--cached"), + QStringLiteral("--others"), + QStringLiteral("--exclude-standard")}); + if (!git.waitForStarted(3000)) { + return QStringList(); + } + if (!git.waitForFinished(10000)) { + git.kill(); + git.waitForFinished(1000); + return QStringList(); + } + if (git.exitStatus() != QProcess::NormalExit || git.exitCode() != 0) { + return QStringList(); + } + + const QString out = QString::fromUtf8(git.readAllStandardOutput()); + QStringList files = out.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + // git ls-files de-dups tracked vs untracked already, but be defensive. + files.removeDuplicates(); + files.sort(); + return files; +} + +QStringList ProjectIndex::walkDirectory(const QString &root, + const QStringList &ignoreDirs, + int maxFiles) +{ + QStringList files; + if (root.isEmpty()) { + return files; + } + QDir rootDir(root); + if (!rootDir.exists()) { + return files; + } + const QString absRoot = rootDir.absolutePath(); + const QSet ignore(ignoreDirs.cbegin(), ignoreDirs.cend()); + + // Explicit stack walk so ignored directories are never descended into + // (QDirIterator has no per-directory prune hook). + QStringList stack; + stack.push_back(absRoot); + while (!stack.isEmpty()) { + const QString current = stack.takeLast(); + QDir dir(current); + const QFileInfoList entries = + dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot | QDir::Hidden); + for (const QFileInfo &info : entries) { + if (info.isSymLink()) { + continue; // avoid cycles and escaping the tree + } + if (info.isDir()) { + if (ignore.contains(info.fileName())) { + continue; + } + stack.push_back(info.absoluteFilePath()); + } else if (info.isFile()) { + const QString rel = QDir(absRoot).relativeFilePath(info.absoluteFilePath()); + files.push_back(rel); + if (maxFiles > 0 && files.size() >= maxFiles) { + files.sort(); + return files; + } + } + } + } + files.sort(); + return files; +} + +QStringList ProjectIndex::listFiles(const QString &root) +{ + if (root.isEmpty() || !QDir(root).exists()) { + return QStringList(); + } + if (isGitRoot(root)) { + const QStringList git = gitListFiles(root); + if (!git.isEmpty()) { + return git; + } + // Fall through to the walk if git produced nothing (e.g. git missing). + } + return walkDirectory(root, ignoredDirs()); +} + +} // namespace katecustom diff --git a/src/project/projectindex.h b/src/project/projectindex.h new file mode 100644 index 0000000..a2fd422 --- /dev/null +++ b/src/project/projectindex.h @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * ProjectIndex — "open folder == open project". + * + * A project is just a directory. The root is discovered by walking up from a + * starting path (typically the active document's directory) until a project + * marker is found (.git, .hg, .svn). With no marker the starting directory is + * the root, so an ad-hoc folder is still a usable project. + * + * File enumeration prefers `git ls-files` inside a git work tree (fast, honours + * .gitignore, includes untracked-but-not-ignored files). Outside git it falls + * back to a bounded recursive directory walk that skips well-known noise dirs. + * + * The matching/ranking of the returned list is left to FuzzyRanker / + * PaletteWidget; this class only produces the candidate set. + */ +#ifndef KATECUSTOM_PROJECTINDEX_H +#define KATECUSTOM_PROJECTINDEX_H + +#include +#include + +namespace katecustom +{ + +class ProjectIndex +{ +public: + /*! + * Directory names that mark a project root when encountered while walking + * up from a start path. The first match wins. + */ + static QStringList rootMarkers(); + + /*! + * Directory names skipped by the non-git recursive walk. Keeps the index + * free of build output and vendored dependencies. + */ + static QStringList ignoredDirs(); + + /*! + * Walk up from \a startDir (a directory, or a file's directory) looking for + * a \c rootMarkers() entry. Returns the first ancestor that contains one, or + * \a startDir itself when none is found. Returns an empty string if + * \a startDir is empty or does not exist. + */ + static QString findRoot(const QString &startDir); + + /*! + * List project files as paths relative to \a root, using git when \a root is + * a git work tree, otherwise a recursive walk. Returns an empty list when + * \a root is empty or missing. Results are sorted. + */ + static QStringList listFiles(const QString &root); + + /*! + * Recursive directory walk returning file paths relative to \a root, sorted. + * Directories whose name is in \a ignoreDirs are not descended into. Pure; + * no external process. \a maxFiles caps the result to keep huge trees sane + * (0 = no cap). + */ + static QStringList walkDirectory(const QString &root, + const QStringList &ignoreDirs, + int maxFiles = 50000); + + /*! + * True if \a dir is the top of a git work tree, i.e. a `.git` entry exists + * directly in \a dir. + */ + static bool isGitRoot(const QString &dir); + +private: + /*! Run `git -C root ls-files` (tracked + untracked-not-ignored), sorted. */ + static QStringList gitListFiles(const QString &root); +}; + +} // namespace katecustom + +#endif diff --git a/src/project/test_projectindex.cpp b/src/project/test_projectindex.cpp new file mode 100644 index 0000000..fcd2d7b --- /dev/null +++ b/src/project/test_projectindex.cpp @@ -0,0 +1,153 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Unit tests for ProjectIndex. Exercises the pure logic (root discovery and the + * recursive directory walk) against a temporary tree. The git path is covered + * only indirectly: listFiles() falls back to the walk when the tree is not a + * git work tree. + */ +#include "projectindex.h" + +#include +#include +#include +#include + +using namespace katecustom; + +namespace +{ +void touch(const QString &path) +{ + QFile f(path); + QVERIFY2(f.open(QIODevice::WriteOnly), qPrintable(path)); + f.write("x"); + f.close(); +} +} // namespace + +class TestProjectIndex : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void findRootWalksUpToMarker() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("src/sub"))); + + const QString start = base.filePath(QStringLiteral("src/sub")); + const QString root = ProjectIndex::findRoot(start); + QCOMPARE(QDir(root).canonicalPath(), QDir(tmp.path()).canonicalPath()); + } + + void findRootAcceptsFilePath() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("src"))); + const QString file = base.filePath(QStringLiteral("src/main.cpp")); + touch(file); + + const QString root = ProjectIndex::findRoot(file); + QCOMPARE(QDir(root).canonicalPath(), QDir(tmp.path()).canonicalPath()); + } + + void findRootFallsBackToStartDir() + { + QTemporaryDir tmp; // no marker anywhere + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral("a/b"))); + const QString start = base.filePath(QStringLiteral("a/b")); + + const QString root = ProjectIndex::findRoot(start); + QCOMPARE(QDir(root).canonicalPath(), QDir(start).canonicalPath()); + } + + void findRootEmptyOrMissing() + { + QCOMPARE(ProjectIndex::findRoot(QString()), QString()); + QCOMPARE(ProjectIndex::findRoot(QStringLiteral("/no/such/path/xyz")), QString()); + } + + void walkListsFilesRelativeAndSorted() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral("src"))); + touch(base.filePath(QStringLiteral("README.md"))); + touch(base.filePath(QStringLiteral("src/a.cpp"))); + touch(base.filePath(QStringLiteral("src/b.cpp"))); + + const QStringList files = ProjectIndex::walkDirectory(tmp.path(), {}); + QStringList expected{QStringLiteral("README.md"), + QStringLiteral("src/a.cpp"), + QStringLiteral("src/b.cpp")}; + QCOMPARE(files, expected); + } + + void walkSkipsIgnoredDirs() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral("node_modules/pkg"))); + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("src"))); + touch(base.filePath(QStringLiteral("src/main.cpp"))); + touch(base.filePath(QStringLiteral("node_modules/pkg/index.js"))); + touch(base.filePath(QStringLiteral(".git/config"))); + + const QStringList files = + ProjectIndex::walkDirectory(tmp.path(), ProjectIndex::ignoredDirs()); + QCOMPARE(files, QStringList{QStringLiteral("src/main.cpp")}); + } + + void walkRespectsMaxFiles() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + for (int i = 0; i < 10; ++i) { + touch(base.filePath(QStringLiteral("f%1.txt").arg(i))); + } + const QStringList files = ProjectIndex::walkDirectory(tmp.path(), {}, 3); + QCOMPARE(files.size(), 3); + } + + void isGitRootDetectsMarker() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QVERIFY(!ProjectIndex::isGitRoot(tmp.path())); + QVERIFY(QDir(tmp.path()).mkpath(QStringLiteral(".git"))); + QVERIFY(ProjectIndex::isGitRoot(tmp.path())); + } + + void listFilesFallsBackToWalkWhenNoGit() + { + QTemporaryDir tmp; // not a git root + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + touch(base.filePath(QStringLiteral("one.txt"))); + touch(base.filePath(QStringLiteral("two.txt"))); + const QStringList files = ProjectIndex::listFiles(tmp.path()); + QStringList expected{QStringLiteral("one.txt"), QStringLiteral("two.txt")}; + QCOMPARE(files, expected); + } + + void listFilesEmptyRoot() + { + QCOMPARE(ProjectIndex::listFiles(QString()), QStringList()); + } +}; + +QTEST_MAIN(TestProjectIndex) +#include "test_projectindex.moc"