From a327a1a12e08d958896fa5c3c8908e1ed324909e Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 09:48:16 +0200 Subject: [PATCH] Project: explicit 'Open Folder as Project' (sticky root, Alt+O) Replaces reliance on Kate's project plugin with our own folder-as-project model. ProjectIndex::resolveRoot(explicit, start) honours an existing explicit pick EXACTLY (no walk-up, VSCode/Sublime semantics) and otherwise derives via findRoot with cwd fallback (3 new tests, 15 total). Plugin: Alt+O 'Open Folder as Project...' pops a dir chooser, sets a sticky m_projectRoot persisted to $XDG_CONFIG_HOME/olliepalette/project and restored on load (if still present). 'Close Project' reverts to auto-derive. Both in the M-x palette. Switchers now scope to currentProjectRoot() instead of recomputing from the active document. --- docs/PLAN.md | 19 ++++- src/plugin/ollieplugin.cpp | 130 +++++++++++++++++++++++++++--- src/plugin/ollieplugin.h | 12 +++ src/project/projectindex.cpp | 15 ++++ src/project/projectindex.h | 10 +++ src/project/test_projectindex.cpp | 43 ++++++++++ 6 files changed, 213 insertions(+), 16 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index cd91bd0..bce30a1 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -201,12 +201,23 @@ Therefore the palette is replaced, not extended, and the replacement is built on `Editor::queryCommand`/`commandList` and guards against reclaiming `sort`. - A live test (`test_olliecommands_live`, headless KTextEditor doc/view) proves the transforms mutate a real buffer. -- Project = directory ("open folder == open project"): DONE (core + switcher). - `src/project/projectindex.{h,cpp}` (`project_lib`, Qt::Core only, 12 unit - tests): +- Project = directory ("open folder == open project"): DONE (explicit + core + + switcher). `src/project/projectindex.{h,cpp}` (`project_lib`, Qt::Core only, + 15 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. + - `resolveRoot(explicitRoot, startDir)` is the effective-root decision: an + existing explicit pick is honoured **exactly** (no walk-up — VSCode/Sublime + "open folder" semantics); otherwise it derives via `findRoot(startDir)` with + cwd as the final fallback. A stale explicit root reverts to discovery. + - Explicit folder-as-project (our own model; we do **not** use Kate's project + plugin): **Alt+O** "Open Folder as Project…" pops a directory chooser and + makes the pick the **sticky** root. It persists to + `$XDG_CONFIG_HOME/olliepalette/project` and is restored on load (only if it + still exists). "Close Project" (palette `ollie:project:close`) clears it and + reverts to auto-derive. Both are in the M-x palette (`ollie:project:open` / + `:close`), the latter shown only when a project is open. - `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 @@ -218,7 +229,7 @@ Therefore the palette is replaced, not extended, and the replacement is built on whole path (orderless: "palette model" matches `src/palette/palettemodel.cpp`) and highlights matched characters along it; a base-name hit still ranks first because `/` is a word boundary and the length penalty favours shorter paths. - The root is derived from the active document's directory (fallback: cwd). + The root is `currentProjectRoot()` (explicit folder when set, else derived). 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. (2 path-ranking tests in diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 5c844ad..030b703 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -28,6 +28,8 @@ #include #include #include +#include +#include #include #include #include @@ -170,24 +172,19 @@ QList paletteVerbs() }; } -// 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) +// Directory of the active document in \a mainWindow, or empty when there is no +// view, no document, or the document is untitled / not a local file. +QString activeDocumentDirFor(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(); + return QFileInfo(url.toLocalFile()).absolutePath(); } } } - if (startDir.isEmpty()) { - startDir = QDir::currentPath(); - } - return ProjectIndex::findRoot(startDir); + return QString(); } } // namespace @@ -266,6 +263,23 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) w->addAction(gotoSymbolAction); } + // Open a folder as the sticky project root: Alt+O ("Open"). Our own + // folder-as-project model — we do not use Kate's project plugin. The chosen + // folder is honoured exactly and persists across sessions; the Go-to-File + // and Go-to-Symbol switchers are then scoped to it regardless of which file + // is focused. + auto *openProjectAction = new QAction(i18n("Open Folder as Project…"), this); + openProjectAction->setObjectName(QStringLiteral("ollie_open_project")); + openProjectAction->setShortcut(QKeySequence(Qt::ALT | Qt::Key_O)); + openProjectAction->setShortcutContext(Qt::ApplicationShortcut); + connect(openProjectAction, &QAction::triggered, this, &OllieView::openProjectFolder); + if (QWidget *w = m_mainWindow->window()) { + w->addAction(openProjectAction); + } + + // Restore the previously opened project folder (if any, and still present). + loadProjectRoot(); + setupRadials(); // One application-wide filter serves both the mouse-button radial gesture @@ -451,6 +465,23 @@ void OllieView::showCommandPalette() items.push_back(item); } + // Project management: open a folder as the project, or close it (revert to + // auto-deriving the root from the active document). + { + PaletteItem item; + item.id = QStringLiteral("ollie:project:open"); + item.label = i18n("Open Folder as Project…"); + item.group = QStringLiteral("Alt+O"); + items.push_back(item); + } + if (!m_projectRoot.isEmpty()) { + PaletteItem item; + item.id = QStringLiteral("ollie:project:close"); + item.label = i18n("Close Project (%1)", QDir(m_projectRoot).dirName()); + item.group = m_projectRoot; + items.push_back(item); + } + // Apply the persisted frecency bonus so habitual commands rank higher. const qint64 now = QDateTime::currentSecsSinceEpoch(); for (PaletteItem &item : items) { @@ -479,6 +510,71 @@ void OllieView::recordUsage(const QString &id) m_frecency.save(); } +QString OllieView::activeDocumentDir() const +{ + return activeDocumentDirFor(m_mainWindow); +} + +QString OllieView::currentProjectRoot() const +{ + // Explicit folder wins (sticky, VSCode/Sublime style); otherwise derive + // from the active document, falling back to cwd inside resolveRoot(). + return ProjectIndex::resolveRoot(m_projectRoot, activeDocumentDir()); +} + +void OllieView::openProjectFolder() +{ + // Start the chooser at the current effective root so re-picking a nearby + // folder is quick. + const QString start = currentProjectRoot(); + const QString dir = QFileDialog::getExistingDirectory( + m_mainWindow->window(), i18n("Open Folder as Project"), start, + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (dir.isEmpty()) { + return; // cancelled + } + m_projectRoot = QDir(dir).absolutePath(); + saveProjectRoot(); +} + +void OllieView::closeProjectFolder() +{ + m_projectRoot.clear(); + saveProjectRoot(); // writes an empty marker (removes the sticky root) +} + +void OllieView::loadProjectRoot() +{ + const QString cfgDir = + QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); + const QString path = QDir(cfgDir).filePath(QStringLiteral("olliepalette/project")); + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QString saved = QString::fromUtf8(f.readAll()).trimmed(); + f.close(); + // Only restore a folder that still exists; a moved/deleted project silently + // reverts to auto-derive. + if (!saved.isEmpty() && QDir(saved).exists()) { + m_projectRoot = saved; + } +} + +void OllieView::saveProjectRoot() const +{ + const QString cfgDir = + QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); + const QString path = QDir(cfgDir).filePath(QStringLiteral("olliepalette/project")); + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + return; + } + f.write(m_projectRoot.toUtf8()); + f.close(); +} + void OllieView::runAction(const QString &actionId) { // Count every command-palette activation toward frecency (verbs, switcher @@ -509,6 +605,16 @@ void OllieView::runAction(const QString &actionId) return; } + // Project management. + if (actionId == QLatin1String("ollie:project:open")) { + openProjectFolder(); + return; + } + if (actionId == QLatin1String("ollie:project:close")) { + closeProjectFolder(); + return; + } + QPointer action = m_actionsById.value(actionId); if (action) { action->trigger(); @@ -532,7 +638,7 @@ void OllieView::showFileSwitcher() }); } - m_fileSwitcherRoot = projectRootFor(m_mainWindow); + m_fileSwitcherRoot = currentProjectRoot(); const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot); const qint64 now = QDateTime::currentSecsSinceEpoch(); @@ -591,7 +697,7 @@ void OllieView::showSymbolSwitcher() }); } - m_symbolSwitcherRoot = projectRootFor(m_mainWindow); + m_symbolSwitcherRoot = currentProjectRoot(); const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot); const QList symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files); const qint64 now = QDateTime::currentSecsSinceEpoch(); diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index a91e107..ae23d9d 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -66,11 +66,22 @@ private Q_SLOTS: void openProjectFile(const QString &relativePath); void showSymbolSwitcher(); void goToSymbol(const QString &locationId); + void openProjectFolder(); // pick a folder; make it the sticky project root + void closeProjectFolder(); // forget the explicit root (back to auto-derive) private: // Record one activation of \a id and persist. Feeds the frecency bonus so // habitual choices float up in every palette. void recordUsage(const QString &id); + + // The effective project root: the explicitly opened folder when set, + // otherwise derived from the active document's directory (fallback cwd). + QString currentProjectRoot() const; + // Directory of the active document, or empty when none/untitled. + QString activeDocumentDir() const; + // Persist / restore the explicit project root across sessions. + void loadProjectRoot(); + void saveProjectRoot() const; // One harvested, runnable action with the provenance needed to build a // unique id and to disambiguate labels shared by several commands. struct ActionEntry { @@ -127,6 +138,7 @@ private: bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord QHash> m_actionsById; FrecencyStore m_frecency; // persisted usage feeding the palette ranking + QString m_projectRoot; // explicitly opened folder (empty = auto-derive) }; } // namespace katecustom diff --git a/src/project/projectindex.cpp b/src/project/projectindex.cpp index e74e810..ac590b0 100644 --- a/src/project/projectindex.cpp +++ b/src/project/projectindex.cpp @@ -67,6 +67,21 @@ QString ProjectIndex::findRoot(const QString &startDir) return dir.absolutePath(); } +QString ProjectIndex::resolveRoot(const QString &explicitRoot, const QString &startDir) +{ + if (!explicitRoot.isEmpty()) { + const QDir chosen(explicitRoot); + if (chosen.exists()) { + // Honour the user's explicit pick exactly — do not walk up to a + // VCS root above it (VSCode/Sublime "open folder" semantics). + return chosen.absolutePath(); + } + // A stale explicit root (deleted/moved) falls through to discovery. + } + const QString start = startDir.isEmpty() ? QDir::currentPath() : startDir; + return findRoot(start); +} + bool ProjectIndex::isGitRoot(const QString &dir) { if (dir.isEmpty()) { diff --git a/src/project/projectindex.h b/src/project/projectindex.h index a2fd422..fa7a555 100644 --- a/src/project/projectindex.h +++ b/src/project/projectindex.h @@ -47,6 +47,16 @@ public: */ static QString findRoot(const QString &startDir); + /*! + * Resolve the effective project root. When \a explicitRoot is a non-empty, + * existing directory it is honoured exactly (the user chose this folder as + * the project — no walk-up), returned as an absolute path. Otherwise the + * root is discovered from \a startDir via findRoot(); if \a startDir is + * empty the process working directory is used. Returns empty only when + * nothing resolves. + */ + static QString resolveRoot(const QString &explicitRoot, 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 diff --git a/src/project/test_projectindex.cpp b/src/project/test_projectindex.cpp index fcd2d7b..41a4749 100644 --- a/src/project/test_projectindex.cpp +++ b/src/project/test_projectindex.cpp @@ -147,6 +147,49 @@ private Q_SLOTS: { QCOMPARE(ProjectIndex::listFiles(QString()), QStringList()); } + + void resolveRootHonoursExplicitPick() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + // An explicit pick INSIDE a git repo must be honoured exactly, not + // walked up to the repo root. + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("sub/dir"))); + const QString pick = base.filePath(QStringLiteral("sub/dir")); + + const QString root = ProjectIndex::resolveRoot(pick, base.path()); + QCOMPARE(QDir(root).canonicalPath(), QDir(pick).canonicalPath()); + } + + void resolveRootFallsBackWhenExplicitMissing() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("src"))); + const QString start = base.filePath(QStringLiteral("src")); + + // Explicit root does not exist -> derive from startDir (walks up to .git). + const QString root = + ProjectIndex::resolveRoot(QStringLiteral("/no/such/dir/xyz"), start); + QCOMPARE(QDir(root).canonicalPath(), QDir(tmp.path()).canonicalPath()); + } + + void resolveRootDerivesWhenNoExplicit() + { + QTemporaryDir tmp; + QVERIFY(tmp.isValid()); + QDir base(tmp.path()); + QVERIFY(base.mkpath(QStringLiteral(".git"))); + QVERIFY(base.mkpath(QStringLiteral("a/b"))); + const QString start = base.filePath(QStringLiteral("a/b")); + + const QString root = ProjectIndex::resolveRoot(QString(), start); + QCOMPARE(QDir(root).canonicalPath(), QDir(tmp.path()).canonicalPath()); + } }; QTEST_MAIN(TestProjectIndex)