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
+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;