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.
This commit is contained in:
parent
5b31bf6e40
commit
a327a1a12e
19
docs/PLAN.md
19
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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@
|
|||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEvent>
|
||||
#include <QFileDialog>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QKeyEvent>
|
||||
|
|
@ -170,24 +172,19 @@ QList<VerbItem> 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<QAction> 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<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
|
||||
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||
|
|
|
|||
|
|
@ -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<QString, QPointer<QAction>> m_actionsById;
|
||||
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
|
||||
QString m_projectRoot; // explicitly opened folder (empty = auto-derive)
|
||||
};
|
||||
|
||||
} // namespace katecustom
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue