F1 steps 2-6: the Storage App, its viewers, and Maintenance moved in (#3)

The Storage App browses the SD card: folders first with sizes and dates,
three sorts, one item at a time with a clipboard (c, x, v), rename, delete
after counting what's inside, new folder, details. A listing holds 256
entries and says when a folder has more.

FileOps does the card's work for the App and the console alike, one
operation at a time on the storage task in turns of about 150 ms, so Logs
and Captures are still written during a long copy. A copy shows progress,
can be cancelled (what it wrote is taken back) and compares sizes after.
The read-only rules are checked there: the firmware's top-level folders,
/gemini/cache, and files being written (a Track, a Capture, an upload,
today's IRC Logs). A listing reads the folder straight from FatFs: through
the Arduino File, 329 entries took over two seconds.

Viewers by type: text read a screen at a time whatever the file's size
(logs open at the end), a hex dump, a Capture's packets as the LoRa Scanner
lists them, a Track's summary, and an Update File checked as an install
would check it, without writing anything. Tab shows any file as hex or text.

Settings > Storage is gone: usage, Storage Clean-up and Erase are the App's
Maintenance, behind a warning. The Storage Warning points there.

The Clock sets the system time whatever its source, so files are dated
correctly with a GNSS Fix alone (Q137).

Console: cp, mv, mkdir, du, cancel; rm takes folders and follows the rules;
ls shows dates; Debug Builds get `sd fill`.

424 host tests. Checked on the device: docs/milestones/F1.md.

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-06 08:45:43 +02:00
co-authored by Claude Opus 5.5
parent 7ab8f043d0
commit b7aed8e91c
28 changed files with 2660 additions and 41 deletions
+566
View File
@@ -0,0 +1,566 @@
#include "storage_app.h"
#include <Arduino.h>
#include <algorithm>
#include <cstdio>
#include "cleanup_plan.h"
#include "file_names.h"
#include "ui/canvas.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
namespace roro {
using files::baseName;
using files::joinPath;
using files::parentOf;
namespace {
constexpr uint32_t kMessageMs = 4000;
constexpr uint32_t kQuietMs = 300; // an operation shorter than this shows no progress
const char* kindText(const std::string& name) {
switch (files::kindOf(name)) {
case files::FileKind::Text: return "Text";
case files::FileKind::Gpx: return "Track (GPX)";
case files::FileKind::Pcap: return "Capture (pcap)";
case files::FileKind::Ota: return "Update File";
default: return "File";
}
}
std::string plural(uint32_t n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); }
} // namespace
void StorageApp::onEnter() {
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::Browse;
ask_ = Ask::None;
dialog_.reset();
message_.clear();
cardPresent_ = storage_.state().present;
// A copy left running is picked up where it is; anything else starts from a fresh listing.
if (wait_ != Wait::Work && view_ == View::Browse) open(cwd_);
else want_ = cwd_;
requestRedraw();
}
void StorageApp::onExit() {
if (wait_ != Wait::None && wait_ != Wait::Work) {
ops_.cancel();
wait_ = Wait::None;
}
viewer_.close();
wantList_ = false;
loaded_ = false;
list_ = files::FileList(); // its memory only while the App is open
rows_.setCount(0);
std::vector<std::string>().swap(details_);
}
void StorageApp::say(const std::string& text) {
message_ = text;
messageMs_ = millis();
requestRedraw();
}
// What's on screen stays, with its path, until the new listing is in: cwd_ changes then.
void StorageApp::open(const std::string& folder, const std::string& select) {
want_ = folder;
selectAfter_ = select;
wantList_ = true; // started from update(), once the card is free
waitSinceMs_ = millis();
}
StorageApp::Item StorageApp::selected() const {
int i = rows_.selected();
if (!loaded_ || i < 0 || i >= static_cast<int>(list_.count())) return {};
Item item;
item.path = joinPath(cwd_, list_.name(i));
item.folder = list_.folder(i);
return item;
}
bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
if (!why.empty()) {
say(why);
return false;
}
wait_ = Wait::Work;
waitSinceMs_ = millis();
selectAfter_ = selectAfter;
return true;
}
void StorageApp::update(uint32_t nowMs) {
bool present = storage_.state().present;
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
cardPresent_ = present;
cwd_ = want_ = "/";
selectAfter_.clear();
loaded_ = false;
list_.clear();
rows_.setCount(0);
wantList_ = present;
clip_ = Item();
if (view_ == View::Viewer) viewer_.close();
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
requestRedraw();
}
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
wantList_ = false;
wait_ = Wait::List;
waitSinceMs_ = nowMs;
}
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
FileOps::Status s;
if (wait_ != Wait::None && ops_.finished(s)) {
onFinished(s);
requestRedraw();
}
// millis(), not nowMs: a message set by a key in this same pass is newer than the pass's own time.
uint32_t now = millis();
if ((wait_ != Wait::None || wantList_) && now - lastDrawMs_ >= 250) requestRedraw();
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
message_.clear();
requestRedraw();
}
if (view_ == View::Maintenance && now - lastDrawMs_ >= 1000) requestRedraw();
}
void StorageApp::onFinished(const FileOps::Status& s) {
Wait was = wait_;
wait_ = Wait::None;
switch (was) {
case Wait::List: {
if (!s.error.empty()) {
say(s.error);
selectAfter_.clear();
// A folder that can't be opened leaves the screen as it is; if it's the one on
// screen that went away, back to the top.
if (want_ != cwd_ && loaded_) {
want_ = cwd_;
return;
}
if (want_ != "/") return open("/");
list_.clear();
} else {
ops_.takeListing(list_);
list_.sort(sort_);
}
cwd_ = want_;
loaded_ = true;
int count = static_cast<int>(list_.count()) + (cwd_ == "/" ? 1 : 0);
int at = selectAfter_.empty() ? -1 : list_.find(selectAfter_);
rows_.setCount(count);
rows_.select(at >= 0 ? at : selectIndex_);
selectAfter_.clear();
selectIndex_ = 0;
break;
}
case Wait::CountToDelete:
if (!s.error.empty()) say(s.error);
else if (!s.cancelled) askDelete(target_, s.files, s.bytes);
break;
case Wait::CountForDetails:
if (view_ != View::Details || details_.empty() || s.cancelled) break;
details_.back() = s.error.empty() ? plural(s.files, "file") + ", " + formatBytes(s.bytes) : s.error;
break;
case Wait::Work: {
std::string name = files::fitName(baseName(target_.path), 20);
if (s.exists && pasting_) {
question_ = name + " exists already in this folder. Replace it?";
ask_ = Ask::Replace;
dialog_.reset(new DialogModel({"Cancel", "Replace"}));
return;
}
if (!s.error.empty()) say(s.error);
else if (s.cancelled) say(s.op == FileOps::Op::Copy ? "Cancelled: nothing was copied" : "Cancelled after " + plural(s.files, "file"));
else {
switch (s.op) {
case FileOps::Op::Copy: say(s.files == 1 ? "Copied " + name : "Copied " + plural(s.files, "file") + " to " + name); break;
case FileOps::Op::Move: say((pasting_ ? "Moved " : "Renamed to ") + name); break;
case FileOps::Op::Delete: say(s.files == 1 ? "Deleted " + name : "Deleted " + name + ", " + plural(s.files, "file")); break;
default: say("Made " + name); break;
}
// The clipboard must not point at what has moved or gone.
bool gone = s.op == FileOps::Op::Delete || s.op == FileOps::Op::Move;
if (clip_ && gone && files::isInside(clip_.path, s.path)) clip_ = Item();
}
open(cwd_, selectAfter_);
break;
}
default: break;
}
}
void StorageApp::paste(bool replace) {
if (!clip_) return say("Nothing to paste: c copies, x cuts");
std::string name = baseName(clip_.path), why;
if (cut_) {
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
} else {
// A copy next to its original: "a (2).txt", or the next number that's free.
if (parentOf(clip_.path) == cwd_)
for (int n = 2; n < 100 && list_.find(name) >= 0; n++) name = files::copyName(baseName(clip_.path), n);
why = ops_.copy(clip_.path, clip_.folder, cwd_, name, replace);
}
target_.path = joinPath(cwd_, name);
target_.folder = clip_.folder;
pasting_ = true;
selectIndex_ = rows_.selected(); // where to stay if nothing comes of it
work(why, name);
}
void StorageApp::askDelete(const Item& item, uint32_t count, uint32_t bytes) {
std::string name = files::fitName(baseName(item.path), 22);
if (!item.folder) question_ = "Delete " + name + " (" + formatBytes(bytes) + ")?";
else if (count == 0) question_ = "Delete the empty folder " + name + "?";
else question_ = "Delete " + name + " and its " + plural(count, "file") + " (" + formatBytes(bytes) + ")?";
question_ += " It can't be undone.";
target_ = item;
ask_ = Ask::Delete;
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
requestRedraw();
}
void StorageApp::onAnswer(int button) {
Ask asked = ask_;
ask_ = Ask::None;
dialog_.reset();
if (button != 1) return;
switch (asked) {
case Ask::Delete:
selectIndex_ = rows_.selected();
pasting_ = false;
work(ops_.remove(target_.path, target_.folder), "");
break;
case Ask::Replace: paste(true); break;
case Ask::Maintenance:
view_ = View::Maintenance;
maintenance_.enter();
break;
default: break;
}
}
void StorageApp::showDetails(const Item& item) {
int i = rows_.selected();
std::string name = baseName(item.path);
details_.clear();
details_.push_back(name);
if (item.folder) details_.push_back("Folder");
else {
uint32_t size = list_.size(i);
std::string text = std::string(kindText(name)) + ", " + formatBytes(size);
if (size >= 1024) text += " (" + std::to_string(size) + " bytes)";
details_.push_back(text);
}
details_.push_back("Modified " + files::formatStamp(list_.modified(i)));
details_.push_back("In " + cwd_);
std::string why = ops_.whyReadOnly(item.path, item.folder);
if (!why.empty()) details_.push_back("Read-only: " + why);
if (item.folder) {
details_.push_back("Counting...");
if (ops_.count(item.path).empty()) wait_ = Wait::CountForDetails;
else details_.pop_back();
}
detailsTop_ = 0;
view_ = View::Details;
}
bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
if (view_ == View::Maintenance) {
if (!maintenance_.onKey(e)) {
view_ = View::Browse;
open(cwd_); // a Clean-up may have emptied what was shown
}
return true;
}
if (view_ == View::Viewer) {
if (!viewer_.onKey(e)) {
viewer_.close();
view_ = View::Browse;
}
return true;
}
if (dialog_) {
dialog_->onKey(e);
if (dialog_->result() != DialogModel::kPending) onAnswer(dialog_->result());
return true;
}
if (view_ == View::Details) {
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
else if (e.key == Key::Down) detailsTop_++; // clamped when drawn
else if (e.key == Key::Back || e.key == Key::Select) {
if (wait_ == Wait::CountForDetails) ops_.cancel();
view_ = View::Browse;
}
return true;
}
// A copy, a delete or a count is running: Back stops it, nothing else gets through. The same
// while a folder is being read, which nothing stops.
if (wantList_) return true;
if (wait_ != Wait::None) {
if (e.key == Key::Back && wait_ != Wait::List) ops_.cancel();
return true;
}
if (view_ == View::Name) return onNameKey(e);
return onBrowseKey(e);
}
bool StorageApp::onNameKey(const KeyEvent& e) {
switch (e.key) {
case Key::Char: editor_.insert(e.ch); break;
case Key::Delete: editor_.backspace(); break;
case Key::Left: editor_.left(); break;
case Key::Right: editor_.right(); break;
case Key::Back: view_ = View::Browse; break;
case Key::Select: {
const std::string& name = editor_.text();
std::string why = files::checkName(name);
if (!why.empty()) {
say(why);
break;
}
pasting_ = false;
bool started = true;
if (!renaming_) {
target_.path = joinPath(cwd_, name);
target_.folder = true;
started = work(ops_.makeFolder(target_.path), name);
} else if (name != baseName(target_.path)) {
std::string to = joinPath(cwd_, name);
started = work(ops_.move(target_.path, target_.folder, to), name);
if (started) target_.path = to;
}
if (started) view_ = View::Browse;
break;
}
default: break;
}
return true;
}
bool StorageApp::onBrowseKey(const KeyEvent& e) {
auto askMaintenance = [this]() {
question_ = "Clean-up and Erase delete files for good: there is no undo.";
ask_ = Ask::Maintenance;
dialog_.reset(new DialogModel({"Back", "Go on"}));
};
if (e.key == Key::Back) {
if (cwd_ == "/" || !cardPresent_) return false; // leaves the App
open(parentOf(cwd_), baseName(cwd_));
return true;
}
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
if (ch == 'm') askMaintenance();
if (!cardPresent_ || !loaded_) return true;
Item item = selected();
switch (e.key) {
case Key::Up: rows_.up(); return true;
case Key::Down: rows_.down(); return true;
case Key::Left: rows_.pageUp(); return true;
case Key::Right: rows_.pageDown(); return true;
case Key::Select:
if (onMaintenanceRow()) askMaintenance();
else if (item && item.folder) open(item.path);
else if (item) {
viewer_.open(item.path, list_.size(rows_.selected()));
view_ = View::Viewer;
}
return true;
case Key::Delete: ch = 'd'; break;
default: break;
}
switch (ch) {
case 'v': paste(false); return true;
case 'n':
renaming_ = false;
editor_.setText("");
view_ = View::Name;
return true;
case 's': {
sort_ = sort_ == files::FileSort::Name ? files::FileSort::Date : sort_ == files::FileSort::Date ? files::FileSort::Size : files::FileSort::Name;
std::string keep = item ? baseName(item.path) : "";
bool onLast = onMaintenanceRow();
list_.sort(sort_);
if (!onLast) rows_.select(std::max(0, list_.find(keep)));
say(sort_ == files::FileSort::Name ? "Sorted by name" : sort_ == files::FileSort::Date ? "Sorted by date, newest first" : "Sorted by size, biggest first");
return true;
}
default: break;
}
if (!item || !(ch == 'c' || ch == 'x' || ch == 'r' || ch == 'd' || ch == 'i')) return true;
std::string name = files::fitName(baseName(item.path), 18);
if (ch == 'i') {
showDetails(item);
return true;
}
if (ch == 'c') {
clip_ = item;
cut_ = false;
say("v pastes a copy of " + name);
return true;
}
// Cut, rename and delete change the original: the rules of Q130 first.
std::string why = ops_.whyReadOnly(item.path, item.folder);
if (!why.empty()) {
say(why);
return true;
}
if (ch == 'x') {
clip_ = item;
cut_ = true;
say("v moves " + name + " here");
} else if (ch == 'r') {
target_ = item;
renaming_ = true;
editor_.setText(baseName(item.path));
view_ = View::Name;
} else if (item.folder) { // 'd': what's inside is counted first (Q132)
target_ = item;
std::string busy = ops_.count(item.path);
if (!busy.empty()) say(busy);
else {
wait_ = Wait::CountToDelete;
waitSinceMs_ = millis();
}
} else {
askDelete(item, 1, list_.size(rows_.selected()));
}
return true;
}
void StorageApp::draw(Canvas& c) {
lastDrawMs_ = millis();
const auto& area = theme::kContent;
c.setTextDatum(top_left);
switch (view_) {
case View::NoMemory:
c.setFont(&fonts::bold);
c.setTextColor(theme::kWarning);
c.drawString("Not enough memory", 4, area.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Close what's running (IRC, a page", 4, area.y + 22);
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
return;
case View::Maintenance: maintenance_.draw(c); return;
case View::Viewer: viewer_.draw(c); return;
case View::Details: {
std::vector<std::string> lines;
auto measure = widgets::bodyMeasure(c);
for (auto& d : details_)
for (auto& l : wrapText(d, area.w - 12, measure)) lines.push_back(l);
int rows = (area.h - 2) / theme::kLineHeight;
detailsTop_ = std::clamp(detailsTop_, 0, std::max(0, static_cast<int>(lines.size()) - rows));
widgets::textLines(c, lines, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
return;
}
default: break;
}
drawBrowse(c);
if (view_ == View::Name) {
theme::Rect box{16, 44, theme::kWidth - 32, 48};
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
c.drawString(renaming_ ? "Rename" : "New folder", box.x + 6, box.y + 4);
widgets::lineEditor(c, editor_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
}
if (dialog_) {
const char* title = ask_ == Ask::Delete ? "Delete" : ask_ == Ask::Replace ? "Replace" : "Maintenance";
widgets::dialog(c, title, question_, *dialog_);
} else if ((wait_ == Wait::Work || wait_ == Wait::CountToDelete) && millis() - waitSinceMs_ >= kQuietMs) {
drawProgress(c);
}
}
void StorageApp::drawBrowse(Canvas& c) {
const auto& area = theme::kContent;
StorageState card = storage_.state();
// Where we are, and how much room is left (Q129).
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string right;
if (card.present) {
right = formatBytes(card.totalBytes - card.usedBytes) + " free";
if (loaded_ && list_.more() > 0) right = "first " + std::to_string(list_.count()) + " of " + std::to_string(list_.count() + list_.more());
}
size_t room = static_cast<size_t>((area.w - 12) / 5) - right.size() - 1;
std::string path = cwd_.size() <= room ? cwd_ : ".." + cwd_.substr(cwd_.size() - (room - 2));
c.drawString(path.c_str(), 4, area.y + 1);
c.setTextDatum(top_right);
c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
c.setFont(&fonts::body);
if (!card.present) {
c.setTextColor(theme::kWarning);
c.drawString("No SD card", 4, rows.y + 4);
} else if (!loaded_) {
// Nothing yet: the footer says what's being read.
} else if (rows_.count() == 0) {
c.setTextColor(theme::kMuted);
c.drawString("Nothing in this folder.", 4, rows.y + 4);
c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight);
} else {
int entries = static_cast<int>(list_.count());
auto value = [&](int i) {
return i >= entries ? std::string(">") : files::rowDetail(list_.folder(i), list_.size(i), list_.modified(i));
};
widgets::list(
c, rows_, rows,
[&](int i) {
if (i >= entries) return std::string("Maintenance");
size_t fits = static_cast<size_t>((area.w - 22) / 6) - value(i).size();
return files::fitName(list_.name(i), fits);
},
value);
}
// Footer: a message for a few seconds, what v would paste, or the keys.
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = "c x v:paste r:name d:del n:new i:info s:sort";
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here";
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
}
void StorageApp::drawProgress(Canvas& c) {
FileOps::Status s = ops_.status();
theme::Rect box{20, 40, theme::kWidth - 40, 58};
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
c.setTextDatum(top_left);
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
const char* verb = s.op == FileOps::Op::Copy ? "Copying " : s.op == FileOps::Op::Delete ? "Deleting " : s.op == FileOps::Op::Count ? "Counting " : "Moving ";
c.drawString((verb + files::fitName(baseName(target_.path), 20)).c_str(), box.x + 6, box.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
std::string line = plural(s.files, "file");
if (s.op == FileOps::Op::Copy && s.totalBytes > 0) {
int w = box.w - 12, done = static_cast<int>(static_cast<uint64_t>(s.doneBytes) * w / s.totalBytes);
c.drawRect(box.x + 6, box.y + 21, w, 7, theme::kMuted);
c.fillRect(box.x + 6, box.y + 21, std::min(done, w), 7, theme::kAccent);
line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
}
c.drawString(line.c_str(), box.x + 6, box.y + 31);
c.setFont(&fonts::small);
c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10);
}
} // namespace roro