From 668397c46a17b59ae82e2a064e5e0eaecfa9ee2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Wed, 11 Nov 2026 20:59:23 +0100 Subject: [PATCH 1/3] Let a plugin's watch say when something changed A poll asks on a timer, and a file's details are asked for once and kept, so a plugin whose state changes on its own (a sync finishing) showed it late, or not until F5. A manifest's "watch" is a command Folio leaves running; each line it prints has the plugin polled at once and its details asked for again. Lines that come close together are acted on once, a watch that ends is started again every 5 seconds, and it ends with Folio or its plugin, with everything it started. Co-Authored-By: Claude Fable 5.2 --- docs/plugins.md | 45 +++++++++++- src/plugins.cpp | 167 ++++++++++++++++++++++++++++++++++++++++++ src/plugins.h | 43 ++++++++++- tests/folio_tests.cpp | 94 ++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 3c4663a4..2e2bc3d1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -227,6 +227,7 @@ Unknown keys are ignored, so a manifest written for a later Folio still loads. | `view` | A view of all the plugin's items in the main pane (see [The view](#the-view)). | | `actions` | Entries in the right-click menu, in the menu itself or under Share (see [Actions](#actions)). | | `details` | Rows in the details pane about a file or folder (see [Details](#details)). | +| `watch` | `{"command": [...]}`: a command that stays running and says when something changed, so Folio asks again at once (see [Watching](#watching)). | ## Running commands @@ -265,6 +266,7 @@ How long a command may take depends on what it's for: waits until the first is done. Closing Folio ends it. - An action with `"detach": true` is started and forgotten, for programs that open a window of their own. +- A **watch** runs for as long as Folio does (see [Watching](#watching)). ### Text @@ -341,6 +343,43 @@ row, the one whose id sorts first is shown. Leave it out and every action is available. A plugin with a poll offers no actions while its poll is failing. +## Watching + +```json +"watch": { "command": ["./watch"] } +``` + +A poll asks on a timer, and a file's details are asked for once and kept, so +a plugin whose state changes on its own (a sync finishing, a machine coming +online) shows it late, or not until F5. A watch says when: Folio starts the +command when the plugins are read and leaves it running, and every line it +prints means something changed. Folio then runs the plugin's poll at once, +whatever its `every`, and asks its details command again about the file the +details pane shows. + +```sh +#!/bin/sh +# Says so whenever something in the notes folder changes. +exec inotifywait -m -q -r -e close_write,move,create,delete --format changed "$HOME/Notes" +``` + +What a line says isn't read, only that it came, so print whatever helps when +you run the command yourself. The first line is acted on at once; lines that +follow within half a second are acted on together when it's over, so a +command may print one for every little change without Folio polling for +each. A change that comes while a poll is running has the poll run once more +after it. + +The command is started like any other (in the plugin's folder, with +`FOLIO_PLUGIN_DIR` and `FOLIO_LANGUAGE`, and its standard error going to +Folio's), but with no time limit. It ends, with everything it started, when +Folio closes or the plugin goes away; F5 and a change of language leave a +running one be, unless its manifest's `watch` changed. When it ends by +itself (what it watches isn't running), Folio starts it again 5 seconds +later, and again every 5 seconds until it stays, saying so on its standard +error once. A plugin works without its watch, only later: keep the poll, and +let the watch make it timely. + ## The sidebar section ```json @@ -474,9 +513,9 @@ is fine when there's nothing to say. The rows show below Folio's own, plugins in id order. Folio keeps them for the file as it was last modified, so going through a folder and back runs nothing again. It asks again about a file changed since, about everything -after F5, and about every file once the plugin's poll prints something new, -since a plugin's state can change how a file stands without changing the -file (Dropbox finishing a sync). +after F5, and about every file once the plugin's poll prints something new +or its [watch](#watching) prints a line, since a plugin's state can change +how a file stands without changing the file (Dropbox finishing a sync). The command runs in the background, never holding up the window, and has 2 seconds. One that fails, takes longer or prints something other than a list diff --git a/src/plugins.cpp b/src/plugins.cpp index f5455f92..23ff124b 100644 --- a/src/plugins.cpp +++ b/src/plugins.cpp @@ -161,6 +161,8 @@ Plugins::Plugins(const QString &language, const QStringList &folders, const QStr Plugins::~Plugins() { // Closing Folio ends every command still running, with all it started. + for (const QString &plugin : m_watches.keys()) + endWatch(plugin); *m_stop = true; m_pool.waitForDone(); m_actions.waitForDone(); @@ -278,6 +280,13 @@ std::optional Plugins::parseManifest(const QByteArray &json, m.detailsUnder = *under; m.detailsTypes = *types; } + + if (root.contains("watch")) { + const auto command = commandOf(root.value("watch").toObject().value("command")); + if (!command) + return fail("\"watch\" has no command"); + m.watch = *command; + } return m; } @@ -533,6 +542,43 @@ void Plugins::apply(const QList &manifests) { } // What plugins that went away printed goes with them. m_outputs.removeIf([&](const auto &it) { return !polled.contains(it.key()); }); + + // A watch that's as it was carries on, so F5 or another language doesn't + // start it over; one that couldn't be started gets another go. + QSet watched; + for (const Manifest &m : std::as_const(m_manifests)) { + if (m.watch.isEmpty()) + continue; + watched.insert(m.id); + const auto it = m_watches.constFind(m.id); + if (it != m_watches.constEnd() && it->command == m.watch && it->dir == m.dir + && (it->process || it->restart->isActive())) + continue; + endWatch(m.id); + Watch watch; + watch.command = m.watch; + watch.dir = m.dir; + watch.settle = new QTimer(this); + watch.settle->setSingleShot(true); + watch.settle->setInterval(watchSettle); + connect(watch.settle, &QTimer::timeout, this, [this, id = m.id] { + const auto held = m_watches.find(id); + if (held == m_watches.end() || !held->pending) + return; + held->pending = false; + held->settle->start(); + askAgain(id); + }); + watch.restart = new QTimer(this); + watch.restart->setSingleShot(true); + watch.restart->setInterval(watchRestart); + connect(watch.restart, &QTimer::timeout, this, [this, id = m.id] { startWatch(id); }); + m_watches.insert(m.id, watch); + startWatch(m.id); + } + for (const QString &id : m_watches.keys()) + if (!watched.contains(id)) + endWatch(id); for (const QString &id : polled) if (m_pollAll || !before.contains(id)) poll(id); @@ -569,6 +615,10 @@ void Plugins::poll(const QString &plugin) { connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, plugin, dir = m->dir, command = m->poll] { watcher->deleteLater(); m_polling.remove(plugin); + // Its watch said something changed meanwhile, which this poll may + // have been too early to see. + if (m_pollAgain.remove(plugin)) + QTimer::singleShot(0, this, [this, plugin] { poll(plugin); }); const Manifest *now = manifest(plugin); if (!now) return; @@ -606,6 +656,123 @@ void Plugins::poll(const QString &plugin) { })); } +void Plugins::startWatch(const QString &plugin) { + const Manifest *m = manifest(plugin); + const auto watch = m_watches.find(plugin); + if (!m || watch == m_watches.end() || watch->process) + return; + // Unlike the other commands this one isn't waited for, so it runs here + // and not in a pool: its lines come in as events. + auto *process = new QProcess(this); + if (!prepare(*process, *m, m->watch, environment(*m))) { + delete process; + return; + } + // A group of its own, so ending it ends everything it started too. + process->setChildProcessModifier([] { ::setpgid(0, 0); }); + // Whether this is still the plugin's watch, and not one ended since. + auto current = [this, plugin, process] { + const auto it = m_watches.find(plugin); + return it != m_watches.end() && it->process == process ? &*it : nullptr; + }; + // What a line says isn't read, only that it came. + connect(process, &QProcess::readyReadStandardOutput, this, [this, plugin, process] { + if (process->readAllStandardOutput().contains('\n')) + noticed(plugin); + }); + // Standard error goes to Folio's, a line at a time, as other commands'. + connect(process, &QProcess::readyReadStandardError, this, [plugin, process, current] { + Watch *watch = current(); + const QList pieces = process->readAllStandardError().split('\n'); + for (qsizetype i = 0; watch && i < pieces.size(); ++i) { + watch->error += pieces[i].left(std::max(0, maxErrorLine - watch->error.size())); + if (i + 1 == pieces.size()) + break; + const QString said = QString::fromUtf8(watch->error).trimmed(); + watch->error.clear(); + if (!said.isEmpty()) + complain(plugin, said); + } + }); + // One that can't be started isn't tried again until the plugins are + // read again; one that ends (what it watches went away) is, in a while. + connect(process, &QProcess::errorOccurred, this, [plugin, process, current](QProcess::ProcessError error) { + Watch *watch = current(); + if (!watch || error != QProcess::FailedToStart) + return; + complain(plugin, process->program() + " couldn't be started: " + process->errorString()); + watch->process = nullptr; + process->deleteLater(); + }); + connect(process, &QProcess::finished, this, [this, plugin, process, current] { + Watch *watch = current(); + if (!watch) + return; + // Whatever it left running in the background goes with it. + if (watch->group > 0) + ::kill(-pid_t(watch->group), SIGKILL); + // Said once each time the watch goes from working to ending. + if (!m_failingWatch.contains(plugin)) + complain(plugin, QString("its watch ended, and is started again every %1 seconds").arg(watchRestart / 1000)); + m_failingWatch.insert(plugin); + watch->process = nullptr; + watch->group = 0; + watch->error.clear(); + process->deleteLater(); + watch->restart->start(); + }); + watch->process = process; + process->start(); + watch->group = process->processId(); +} + +void Plugins::endWatch(const QString &plugin) { + const Watch watch = m_watches.take(plugin); + m_failingWatch.remove(plugin); + delete watch.settle; + delete watch.restart; + if (!watch.process) + return; + watch.process->disconnect(this); + if (watch.group > 0) + ::kill(-pid_t(watch.group), SIGKILL); + watch.process->kill(); + watch.process->waitForFinished(1000); + delete watch.process; +} + +void Plugins::noticed(const QString &plugin) { + const auto watch = m_watches.find(plugin); + if (watch == m_watches.end()) + return; + m_failingWatch.remove(plugin); + // The first line is acted on at once; those that follow close behind + // (a sync going through a folder) are acted on once, when the timer + // rings. + if (watch->settle->isActive()) { + watch->pending = true; + return; + } + watch->settle->start(); + askAgain(plugin); +} + +void Plugins::askAgain(const QString &plugin) { + const Manifest *m = manifest(plugin); + if (!m) + return; + if (!m->details.isEmpty()) { + forgetDetails(plugin); + emit detailsChanged(); + } + // A poll already running may have been too early to see the change, + // so another follows it. + if (m_polling.contains(plugin)) + m_pollAgain.insert(plugin); + else + poll(plugin); +} + QString Plugins::icon(const Manifest &plugin, const QString &name) const { if (!name.endsWith(".svg")) return name.isEmpty() ? QStringLiteral("file") : name; diff --git a/src/plugins.h b/src/plugins.h index c2399fd3..20481466 100644 --- a/src/plugins.h +++ b/src/plugins.h @@ -17,13 +17,16 @@ #include #include +class QProcess; class QTimer; // Plugins: folders holding a manifest.json and commands that print JSON // (docs/plugins.md is the interface). Folio never loads a plugin's code; it // runs its commands in a thread pool of its own, each with a time limit // where it has one, and keeps what they print. A plugin that hangs or -// prints nonsense only goes missing, and the others carry on. +// prints nonsense only goes missing, and the others carry on. The one +// command that stays running is a plugin's watch, which says when something +// changed, so Folio asks again then and not only on a timer. // // What the sidebar, the views and the right-click menu show comes from the // latest output kept here, translated with the plugin's own catalog, so @@ -64,6 +67,9 @@ class Plugins : public QObject { // The command giving rows for the details pane, and the files and // folders it's run for. QStringList details, detailsUnder, detailsTypes; + // The command that stays running and prints a line whenever what + // the plugin says may have changed. + QStringList watch; // The plugin's catalog for the language Folio shows. QHash> strings; }; @@ -183,6 +189,11 @@ class Plugins : public QObject { static constexpr int maxOutput = 1 << 20; // Longest line of standard error kept. static constexpr int maxErrorLine = 1000; + // After a watch's line is acted on, how long the lines that follow are + // held to be acted on once; and how long after a watch ends it's + // started again. + static constexpr int watchSettle = 500; + static constexpr int watchRestart = 5000; public: // A mount finished, so items that mount can find their folder. @@ -191,14 +202,21 @@ class Plugins : public QObject { signals: void changed(); // What plugins say about files may have changed (F5, another language, - // or a plugin's poll saying something new), so the details pane asks - // again. + // a plugin's poll saying something new, or its watch saying so), so the + // details pane asks again. void detailsChanged(); private: void reload(bool pollAll); void apply(const QList &manifests); void poll(const QString &plugin); + // Starts a plugin's watch, and ends it with everything it started. + void startWatch(const QString &plugin); + void endWatch(const QString &plugin); + // A watch printed a line: its plugin is polled and asked about files + // again, once for lines that come close together. + void noticed(const QString &plugin); + void askAgain(const QString &plugin); void rebuild(); const Manifest *manifest(const QString &plugin) const; bool available(const Manifest &plugin, const QString &action) const; @@ -226,6 +244,25 @@ class Plugins : public QObject { // The latest output of each plugin whose poll works. QHash m_outputs; QSet m_polling; + // Plugins whose watch said something changed while their poll ran, so + // they're polled once more when it's done. + QSet m_pollAgain; + // Each plugin's watch: the command and folder it was started with, its + // process while it runs, with the group to end and the line of standard + // error it's in the middle of, the timer holding lines that come close + // together (and whether any came), and the one starting it again. + struct Watch { + QStringList command; + QString dir; + QProcess *process = nullptr; + qint64 group = 0; + QByteArray error; + QTimer *settle = nullptr, *restart = nullptr; + bool pending = false; + }; + QHash m_watches; + // Plugins whose watch ended since it last printed a line. + QSet m_failingWatch; QSet m_failing, m_failingDetails; // What details commands printed, by plugin, path and modification time; // who waits for those still running, the first deciding whether its diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index e5278791..06d52e32 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -4157,6 +4157,100 @@ private slots: QCOMPARE(asked.readAll().count('\n'), 1); } + void pluginManifestTakesAWatch() + { + QString error; + const auto manifest = Plugins::parseManifest(R"({"folio": 1, "watch": {"command": ["./watch", "-q"]}})", &error); + QVERIFY(manifest); + QCOMPARE(manifest->watch, QStringList({"./watch", "-q"})); + QVERIFY(!Plugins::parseManifest(R"({"folio": 1, "watch": {}})", &error)); + QCOMPARE(error, QString("\"watch\" has no command")); + } + + void pluginWatchHasItPolledAndAskedAgain() + { + // Its watch prints as many lines as `go` says, each time it appears. + QTemporaryDir folder, files; + write(files.filePath("a.txt"), "x"); + writePlugin(folder.path(), "sync", R"({"folio": 1, "poll": {"command": ["./poll"], "every": 86400}, + "details": {"command": ["./describe"]}, "watch": {"command": ["./watch"]}})", + {{"poll", "#!/bin/sh\necho polled >> polls\necho '{}'\n"}, + {"describe", "#!/bin/sh\necho \"[[\\\"State\\\", \\\"$(cat word 2>/dev/null || echo waiting)\\\"]]\"\n"}, + {"watch", "#!/bin/sh\nwhile :; do\n if [ -e go ]; then\n lines=$(cat go); rm go\n" + " while [ \"$lines\" -gt 0 ]; do echo changed; lines=$((lines - 1)); done\n fi\n" + " sleep 0.1\ndone\n"}}); + auto polls = [&] { return contents(folder.filePath("sync/polls")).count('\n'); }; + Plugins plugins("en", {folder.path()}, files.filePath("folio.conf")); + QSignalSpy changed(&plugins, &Plugins::detailsChanged); + QVERIFY(changed.wait(5000)); + QTRY_COMPARE(polls(), 1); + const QString path = files.filePath("a.txt"); + const QDateTime when = QFileInfo(path).lastModified(); + auto describe = [&] { plugins.describe(path, "text/plain", when, [] { return true; }, [] {}); }; + describe(); + QTRY_COMPARE(plugins.details(path, when), QVariantList({QVariantList{"State", "waiting"}})); + + // A line from the watch: the plugin is polled at once, long before + // its timer, and what it said about the file is asked for again. + changed.clear(); + write(folder.filePath("sync/word"), "synced"); + write(folder.filePath("sync/go"), "1"); + QVERIFY(changed.wait(3000)); + QTRY_COMPARE(polls(), 2); + QVERIFY(plugins.details(path, when).isEmpty()); + describe(); + QTRY_COMPARE(plugins.details(path, when), QVariantList({QVariantList{"State", "synced"}})); + + // Lines that come close together are acted on at once and, for + // those that follow the first, once more: never once each. + QTest::qWait(Plugins::watchSettle + 200); + write(folder.filePath("sync/go"), "20"); + QTRY_VERIFY(polls() >= 3); + QTest::qWait(Plugins::watchSettle * 3); + QVERIFY(polls() <= 4); + } + + void pluginWatchIsStartedAgainAndEndsWithItsPlugin() + { + // One watch ends as soon as it starts; the others stay, each with a + // program of its own in the background. + QTemporaryDir folder, files; + const QByteArray stays = "#!/bin/sh\nsleep 600 &\necho \"$$ $!\" >> pids\nwait\n"; + writePlugin(folder.path(), "quits", R"({"folio": 1, "watch": {"command": ["./watch"]}})", + {{"watch", "#!/bin/sh\necho started >> starts\n"}}); + writePlugin(folder.path(), "removed", R"({"folio": 1, "watch": {"command": ["./watch"]}})", {{"watch", stays}}); + writePlugin(folder.path(), "stays", R"({"folio": 1, "watch": {"command": ["./watch"]}})", {{"watch", stays}}); + auto lines = [&](const QString &file) { return contents(folder.filePath(file)).count('\n'); }; + auto gone = [&](const QString &file) { + const QList pids = contents(folder.filePath(file)).trimmed().split(' '); + return pids.size() == 2 && ended(pids[0].toInt()) && ended(pids[1].toInt()); + }; + QByteArray removed; + { + Plugins plugins("en", {folder.path()}, files.filePath("folio.conf")); + QTRY_COMPARE(lines("stays/pids"), 1); + QTRY_COMPARE(lines("removed/pids"), 1); + // A watch that ends is started again in a while. + QTRY_COMPARE_WITH_TIMEOUT(lines("quits/starts"), 2, Plugins::watchRestart + 3000); + + // F5 leaves a running watch be, and ends that of a plugin that + // went away, with what it started. + removed = contents(folder.filePath("removed/pids")); + QDir(folder.filePath("removed")).removeRecursively(); + QSignalSpy read(&plugins, &Plugins::detailsChanged); + plugins.refresh(); + QVERIFY(read.wait(5000)); + QCOMPARE(lines("stays/pids"), 1); + QVERIFY(!gone("stays/pids")); + // What the removed plugin's watch started went with it. + const QList pids = removed.trimmed().split(' '); + QCOMPARE(pids.size(), 2); + QTRY_VERIFY(ended(pids[0].toInt()) && ended(pids[1].toInt())); + } + // Closing Folio ends the rest. + QTRY_VERIFY(gone("stays/pids")); + } + void backendShowsPluginDetailsForTheSelectedItem() { QTemporaryDir folder, home; From 2dbe0adf6a57b1140404d446d73f6c560aefa823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Fri, 13 Nov 2026 14:05:45 +0100 Subject: [PATCH 2/3] Let a details command finish while a watch keeps printing, and print a watch's last error line A watch printing a line every half second had each details command's answer dropped before it came, so a command slower than that showed nothing for a file until the watch went quiet, while the dropped commands kept running. A command still running when its plugin's state changes is now left to finish, its answer shown, and run once more after, as a poll is. F5 still drops the answers on their way. A watch ending on a line of standard error without a newline has it printed too, as other commands do, since it can be the only reason it stopped. Co-Authored-By: Claude Fable 5.2 --- src/plugins.cpp | 54 ++++++++++++++++++++++++----------- src/plugins.h | 8 ++++-- tests/folio_tests.cpp | 65 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/plugins.cpp b/src/plugins.cpp index 23ff124b..f8d80731 100644 --- a/src/plugins.cpp +++ b/src/plugins.cpp @@ -711,6 +711,10 @@ void Plugins::startWatch(const QString &plugin) { // Whatever it left running in the background goes with it. if (watch->group > 0) ::kill(-pid_t(watch->group), SIGKILL); + // Its last line may not have ended, and can say why it stopped. + const QString said = QString::fromUtf8(watch->error).trimmed(); + if (!said.isEmpty()) + complain(plugin, said); // Said once each time the watch goes from working to ending. if (!m_failingWatch.contains(plugin)) complain(plugin, QString("its watch ended, and is started again every %1 seconds").arg(watchRestart / 1000)); @@ -962,22 +966,29 @@ bool Plugins::describe(const QString &path, const QString &mime, const QDateTime void Plugins::ask(const Manifest &plugin, const QString &path, const QString &key) { auto *watcher = new QFutureWatcher>(this); connect(watcher, &QFutureWatcherBase::finished, this, - [this, watcher, id = plugin.id, path, key, generation = m_detailsGeneration[plugin.id]] { + [this, watcher, id = plugin.id, path, key, generation = m_detailsGeneration] { watcher->deleteLater(); - // What was said is forgotten meanwhile, so this answer may be - // older than the one that replaces it. - if (generation != m_detailsGeneration.value(id)) + // Everything said was forgotten meanwhile (F5), so this answer may + // be older than the one that replaces it. + if (generation != m_detailsGeneration) return; + // Its plugin's state changed while it ran, so it may have been too + // early to see the change. + const bool early = m_askAgain.remove(key); const std::optional run = watcher->result(); if (!run) { // Its first caller moved on before its turn; the next, if any, // has it run for them. QList &askers = m_asking[key]; askers.removeFirst(); - if (askers.isEmpty()) - m_asking.remove(key); - else if (const Manifest *m = manifest(id)) + if (const Manifest *m = askers.isEmpty() ? nullptr : manifest(id)) { ask(*m, path, key); + return; + } + // Nobody's left to run it for. An early answer it was to + // replace isn't kept, so the next to ask gets a new one. + m_asking.remove(key); + m_details.remove(key); return; } // One that fails or takes too long has no rows for the file, @@ -994,8 +1005,15 @@ void Plugins::ask(const Manifest &plugin, const QString &path, const QString &ke if (m_details.size() >= 5000) m_details.clear(); m_details.insert(key, said.array()); - for (const Asker &asker : m_asking.take(key)) + // An early answer is shown all the same, or a state that keeps + // changing would leave the file with none; its command runs once + // more, for the same callers. + const Manifest *m = early ? manifest(id) : nullptr; + const QList askers = m ? m_asking.value(key) : m_asking.take(key); + for (const Asker &asker : askers) asker.done(); + if (m && m_asking.contains(key)) + ask(*m, path, key); }); // Stepping through files faster than they're answered for queues // commands; those overtaken before their turn aren't run, so they @@ -1011,20 +1029,22 @@ void Plugins::ask(const Manifest &plugin, const QString &path, const QString &ke } void Plugins::forgetDetails(const QString &plugin) { - // Answers on their way are dropped too, and their callers asked to ask - // again (detailsChanged), so nothing waits on them. Only the plugin's - // own: another's still running answer is as good as it was. + // Every plugin's: answers on their way are dropped too, and their + // callers asked to ask again (detailsChanged), so nothing waits on them. if (plugin.isEmpty()) { m_details.clear(); m_asking.clear(); - for (quint64 &generation : m_detailsGeneration) - ++generation; + m_askAgain.clear(); + ++m_detailsGeneration; return; } - const auto its = [&](const auto &it) { return it.key().startsWith(plugin + '\n'); }; - m_details.removeIf(its); - m_asking.removeIf(its); - ++m_detailsGeneration[plugin]; + // One plugin's: a command still running is left to finish and run once + // more after, as a poll is. Dropping its answer each time would leave + // nothing to show while the plugin's state keeps changing. + m_details.removeIf([&](const auto &it) { return it.key().startsWith(plugin + '\n'); }); + for (auto it = m_asking.cbegin(); it != m_asking.cend(); ++it) + if (it.key().startsWith(plugin + '\n')) + m_askAgain.insert(it.key()); } void Plugins::run(const QString &itemPath, const Done &done) { diff --git a/src/plugins.h b/src/plugins.h index 20481466..d9314947 100644 --- a/src/plugins.h +++ b/src/plugins.h @@ -266,15 +266,17 @@ class Plugins : public QObject { QSet m_failing, m_failingDetails; // What details commands printed, by plugin, path and modification time; // who waits for those still running, the first deciding whether its - // command runs; and how often each plugin's have been forgotten, so - // forgetting one plugin's leaves the others' answers on their way. + // command runs; those whose plugin's state changed while they ran, so + // they're run once more when done; and how often everything has been + // forgotten, so answers on their way then are dropped. struct Asker { std::function wanted; std::function done; }; QHash m_details; QHash> m_asking; - QHash m_detailsGeneration; + QSet m_askAgain; + quint64 m_detailsGeneration = 0; QHash m_timers; QHash>> m_queue; QVariantList m_sections; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 06d52e32..f39f80c2 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -4210,6 +4210,71 @@ private slots: QVERIFY(polls() <= 4); } + void pluginWatchThatKeepsPrintingStillGetsDetailsShown() + { + // Its watch prints a line every 0.2 seconds for as long as `go` is + // there; its details command takes longer than the lines are apart. + QTemporaryDir folder, files; + write(files.filePath("a.txt"), "x"); + writePlugin(folder.path(), "sync", R"({"folio": 1, "details": {"command": ["./describe"]}, + "watch": {"command": ["./watch"]}})", + {{"describe", "#!/bin/sh\necho asked >> asked\nsleep 0.8\n" + "echo \"[[\\\"State\\\", \\\"$(cat word 2>/dev/null || echo waiting)\\\"]]\"\n"}, + {"watch", "#!/bin/sh\nwhile :; do\n [ -e go ] && echo changed\n sleep 0.2\ndone\n"}}); + auto asked = [&] { return contents(folder.filePath("sync/asked")).count('\n'); }; + Plugins plugins("en", {folder.path()}, files.filePath("folio.conf")); + QSignalSpy changed(&plugins, &Plugins::detailsChanged); + QVERIFY(changed.wait(5000)); + const QString path = files.filePath("a.txt"); + const QDateTime when = QFileInfo(path).lastModified(); + int answered = 0; + // As the details pane does: it asks again whenever it's told to. + auto describe = [&] { plugins.describe(path, "text/plain", when, [] { return true; }, [&] { ++answered; }); }; + connect(&plugins, &Plugins::detailsChanged, this, describe); + + // The file gets its rows while the lines keep coming, from a + // command run one at a time and not once a line. + write(folder.filePath("sync/go"), ""); + QVERIFY(changed.wait(3000)); + QElapsedTimer clock; + clock.start(); + QTRY_VERIFY_WITH_TIMEOUT(answered > 0, 3000); + QCOMPARE(plugins.details(path, when), QVariantList({QVariantList{"State", "waiting"}})); + QTest::qWait(2000); + QVERIFY(asked() <= clock.elapsed() / 800 + 1); + + // Once they stop, what's shown is what the plugin says now. + write(folder.filePath("sync/word"), "synced"); + QFile::remove(folder.filePath("sync/go")); + QTRY_COMPARE_WITH_TIMEOUT(plugins.details(path, when), QVariantList({QVariantList{"State", "synced"}}), 5000); + disconnect(&plugins, &Plugins::detailsChanged, this, nullptr); + } + + void pluginWatchSaysWhyItEndedWithoutANewline() + { + // Folio's standard error, read back from a file. + QTemporaryDir folder, files; + writePlugin(folder.path(), "quits", R"({"folio": 1, "watch": {"command": ["./watch"]}})", + {{"watch", "#!/bin/sh\nprintf 'no such folder' >&2\n"}}); + const QString log = files.filePath("stderr"); + fflush(stderr); + const int kept = ::dup(2); + const int file = ::open(qPrintable(log), O_WRONLY | O_CREAT | O_TRUNC, 0600); + QVERIFY(kept >= 0 && file >= 0); + ::dup2(file, 2); + ::close(file); + { + Plugins plugins("en", {folder.path()}, files.filePath("folio.conf")); + QTRY_VERIFY_WITH_TIMEOUT(contents(log).contains("its watch ended"), 5000); + } + fflush(stderr); + ::dup2(kept, 2); + ::close(kept); + const QByteArray said = contents(log); + QVERIFY2(said.contains("folio: plugin quits: no such folder\n"), said.constData()); + QVERIFY(said.indexOf("no such folder") < said.indexOf("its watch ended")); + } + void pluginWatchIsStartedAgainAndEndsWithItsPlugin() { // One watch ends as soon as it starts; the others stay, each with a From 17304e381877f4c3d2bc75e4a92fb81375faac70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Fri, 13 Nov 2026 14:39:32 +0100 Subject: [PATCH 3/3] Let go of a details command's callers once they have their answer While a watch kept printing, every answer was an early one, and its callers were kept for the command's next run while each change added another, so the list grew for as long as the watch went on. Callers are now let go once told, the next run starts without any, and whoever shows the file is told to read it again when that run had nobody waiting. Co-Authored-By: Claude Fable 5.2 --- src/plugins.cpp | 39 ++++++++++++++++++++++----------------- tests/folio_tests.cpp | 5 ++++- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/plugins.cpp b/src/plugins.cpp index f8d80731..bb08ee5d 100644 --- a/src/plugins.cpp +++ b/src/plugins.cpp @@ -955,9 +955,9 @@ bool Plugins::describe(const QString &path, const QString &mime, const QDateTime asking = true; // Asked again while its command runs (another window, or the // folder read again), the caller waits for the same answer. - QList &askers = m_asking[key]; - askers << Asker{wanted, done}; - if (askers.size() == 1) + const bool running = m_asking.contains(key); + m_asking[key] << Asker{wanted, done}; + if (!running) ask(m, path, key); } return asking; @@ -981,14 +981,10 @@ void Plugins::ask(const Manifest &plugin, const QString &path, const QString &ke // has it run for them. QList &askers = m_asking[key]; askers.removeFirst(); - if (const Manifest *m = askers.isEmpty() ? nullptr : manifest(id)) { + if (askers.isEmpty()) + m_asking.remove(key); + else if (const Manifest *m = manifest(id)) ask(*m, path, key); - return; - } - // Nobody's left to run it for. An early answer it was to - // replace isn't kept, so the next to ask gets a new one. - m_asking.remove(key); - m_details.remove(key); return; } // One that fails or takes too long has no rows for the file, @@ -1006,23 +1002,32 @@ void Plugins::ask(const Manifest &plugin, const QString &path, const QString &ke m_details.clear(); m_details.insert(key, said.array()); // An early answer is shown all the same, or a state that keeps - // changing would leave the file with none; its command runs once - // more, for the same callers. - const Manifest *m = early ? manifest(id) : nullptr; - const QList askers = m ? m_asking.value(key) : m_asking.take(key); + // changing would leave the file with none. + const QList askers = m_asking.take(key); for (const Asker &asker : askers) asker.done(); - if (m && m_asking.contains(key)) + // A command run once more had nobody waiting, unless they asked + // since; whoever shows the file is told to read it again. + if (askers.isEmpty()) + emit detailsChanged(); + // After an early answer its command runs once more. Its callers + // have their answer and aren't kept; those who ask meanwhile wait + // for the next. + const Manifest *m = early ? manifest(id) : nullptr; + if (m && !m_asking.contains(key)) { + m_asking.insert(key, {}); ask(*m, path, key); + } }); // Stepping through files faster than they're answered for queues // commands; those overtaken before their turn aren't run, so they // don't hold up the polls behind them. watcher->setFuture(QtConcurrent::run(&m_pool, [plugin, command = plugin.details + QStringList{path}, environment = environment(plugin), - wanted = m_asking.value(key).first().wanted, + wanted = m_asking.value(key).value(0).wanted, stop = m_stop]() -> std::optional { - if (!wanted()) + // One run once more after an early answer has no caller to ask. + if (wanted && !wanted()) return {}; return execute(plugin, command, environment, detailsTimeout, stop.get()); })); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index f39f80c2..10dea215 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -4240,8 +4240,11 @@ private slots: clock.start(); QTRY_VERIFY_WITH_TIMEOUT(answered > 0, 3000); QCOMPARE(plugins.details(path, when), QVariantList({QVariantList{"State", "waiting"}})); - QTest::qWait(2000); + QTest::qWait(3500); QVERIFY(asked() <= clock.elapsed() / 800 + 1); + // Each answer goes to those who asked since the last, not to + // everyone who ever did. + QVERIFY2(answered <= 3 * asked(), qPrintable(QString("%1 answers to %2 commands").arg(answered).arg(asked()))); // Once they stop, what's shown is what the plugin says now. write(folder.filePath("sync/word"), "synced");