Shell: Tab completes a path on the SD card (#67)
CI / build (pull_request) Successful in 1m48s
Site / build (pull_request) Successful in 10s

Past the command's name, Tab completes the word being typed as a path: a
folder keeps its slash to go on from, a file completed whole gets a space,
several candidates are listed. Any case typed, the name's own is taken.
After a file command the first slash is understood (`cat no` is /no).
The folder is read on the storage task, bounded to 400 entries looked at
and 24 candidates.

489 host tests (2 new). Checked on the device: a folder, a file inside it,
several candidates, no slash, another case, nothing matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-07 12:03:44 +02:00
co-authored by Claude Opus 5.5
parent 7b5df713ad
commit d17d10948d
12 changed files with 184 additions and 14 deletions
+6 -3
View File
@@ -123,13 +123,16 @@ bool ShellApp::onKey(const KeyEvent& e) {
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: {
case Key::Tab: { // the command's name, or past it a path on the card
std::vector<std::string> matches;
input_.setText(completeCommand(input_.text(), words_, matches));
PathToComplete path;
bool more = false;
if (splitForPath(input_.text(), path)) input_.setText(completePath(path, list_(path.folder, path.prefix, more), matches));
else input_.setText(completeCommand(input_.text(), words_, matches));
if (matches.size() > 1) {
std::string all;
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
log_.add(all);
log_.add(all + (more ? " ..." : ""));
}
break;
}
+6 -2
View File
@@ -27,9 +27,12 @@ class ShellApp : public App {
// What `rm` is pointed at: it decides whether the Shell asks first.
enum class Target { Missing, File, EmptyFolder, FullFolder };
using Probe = std::function<Target(const std::string& path)>;
// A folder's entries that start with `prefix`, whatever their case, a folder's with a slash at its
// end: what Tab completes a path from. `more` when there were too many to give them all.
using List = std::function<std::vector<std::string>(const std::string& folder, const std::string& prefix, bool& more)>;
ShellApp(Run run, Probe probe, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), helpText_(helpText), apps_(apps) {}
ShellApp(Run run, Probe probe, List list, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), helpText_(helpText), apps_(apps) {}
void onEnter() override;
void onExit() override;
@@ -45,6 +48,7 @@ class ShellApp : public App {
Run run_;
Probe probe_;
List list_;
const char* helpText_;
AppManager& apps_;
ShellLog log_;
+34 -1
View File
@@ -157,6 +157,7 @@ static void setupSafeMode(int crashes);
static const char* helpText();
static void shellRun(const std::string& line);
static ShellApp::Target shellProbe(const std::string& path);
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
void setup() {
nvs.begin();
@@ -215,7 +216,7 @@ void setup() {
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, helpText(), *apps)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
apps->registerApp({"system", "System", false,
@@ -1158,6 +1159,38 @@ static ShellApp::Target shellProbe(const std::string& path) {
return empty ? ShellApp::Target::EmptyFolder : ShellApp::Target::FullFolder;
}
// The entries of a folder that Tab could complete to, for the Shell (issue #67). Read on the storage
// task while the main loop waits, so it is held to what a crowded folder allows: 400 entries looked
// at, 24 given back.
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more) {
std::vector<std::string> names;
more = false;
if (!storageService->state().present) return names;
storageService->runAndWait([&]() {
std::string path = folder.size() > 1 ? folder.substr(0, folder.size() - 1) : folder; // without the slash at its end
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) return;
int seen = 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (++seen > 400) {
more = true;
break;
}
std::string name = f.name();
bool fits = name.size() >= prefix.size();
for (size_t i = 0; fits && i < prefix.size(); i++) fits = tolower(static_cast<unsigned char>(name[i])) == tolower(static_cast<unsigned char>(prefix[i]));
if (!fits) continue;
if (names.size() >= 24) {
more = true;
break;
}
names.push_back(name + (f.isDirectory() ? "/" : ""));
}
});
std::sort(names.begin(), names.end());
return names;
}
static void serialCommands() {
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
static String line;